Merge branch 'main' into feature/chat-api
This commit is contained in:
commit
a64dd983d5
443 changed files with 76715 additions and 5838 deletions
13
.github/dependabot.yml
vendored
13
.github/dependabot.yml
vendored
|
|
@ -9,19 +9,6 @@ updates:
|
|||
actions:
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directories:
|
||||
- "/"
|
||||
- "/studio/backend/plugins/data-designer-unstructured-seed"
|
||||
- "/studio/backend/requirements"
|
||||
- "/unsloth/kernels/moe"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
pip:
|
||||
patterns: ["*"]
|
||||
|
||||
- package-ecosystem: "bun"
|
||||
directory: "/studio/frontend"
|
||||
schedule:
|
||||
|
|
|
|||
226
.github/workflows/release-desktop.yml
vendored
Normal file
226
.github/workflows/release-desktop.yml
vendored
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
name: Release Desktop App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
draft:
|
||||
description: 'Create as draft release'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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 }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
|
||||
# ── 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@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install pinned Tauri CLI
|
||||
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1
|
||||
|
||||
- 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: Install frontend dependencies
|
||||
working-directory: studio/frontend
|
||||
run: npm install
|
||||
|
||||
- name: Verify backend package is published
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'JS'
|
||||
const { readFileSync } = require('node:fs');
|
||||
|
||||
(async () => {
|
||||
const cargo = readFileSync('studio/src-tauri/Cargo.toml', 'utf8');
|
||||
const match = cargo.match(/^version\s*=\s*"([^"]+)"/m);
|
||||
if (!match) throw new Error('Could not read desktop app version');
|
||||
|
||||
const appVersion = match[1];
|
||||
const response = await fetch(`https://pypi.org/pypi/unsloth/${appVersion}/json`);
|
||||
if (!response.ok) {
|
||||
const message = 'Publish unsloth=={app_version} to PyPI before the desktop release';
|
||||
throw new Error(`${message.replace('{app_version}', appVersion)} (HTTP ${response.status})`);
|
||||
}
|
||||
})();
|
||||
JS
|
||||
|
||||
# ── Rust ──
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae
|
||||
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: desktop-v__VERSION__
|
||||
releaseName: 'Unsloth Studio (Desktop) v__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: false
|
||||
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: desktop-v__VERSION__
|
||||
releaseName: 'Unsloth Studio (Desktop) v__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: false
|
||||
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: desktop-v__VERSION__
|
||||
releaseName: 'Unsloth Studio (Desktop) v__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: false
|
||||
args: -v ${{ matrix.args }}
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -204,6 +204,18 @@ tmp/
|
|||
**/node_modules/
|
||||
auth.db
|
||||
|
||||
# Tauri local build/generated output
|
||||
studio/src-tauri/target/
|
||||
studio/src-tauri/gen/
|
||||
studio/src-tauri/artifacts/
|
||||
studio/src-tauri/icons/android/
|
||||
studio/src-tauri/icons/ios/
|
||||
studio/src-tauri/icons/128x128@2x.png
|
||||
studio/src-tauri/icons/64x64.png
|
||||
studio/src-tauri/icons/Square*Logo.png
|
||||
studio/src-tauri/icons/StoreLogo.png
|
||||
studio/src-tauri/icons/squarehq.png
|
||||
|
||||
# Local working docs
|
||||
**/CLAUDE.md
|
||||
**/claude.md
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.8
|
||||
rev: v0.15.12
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
|
|
|
|||
68
README.md
68
README.md
|
|
@ -1,34 +1,50 @@
|
|||
<h1 align="center" style="margin:0;">
|
||||
<a href="https://unsloth.ai/docs"><picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/STUDIO%20WHITE%20LOGO.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/STUDIO%20BLACK%20LOGO.png">
|
||||
<img alt="Unsloth logo" src="https://raw.githubusercontent.com/unslothai/unsloth/main/images/STUDIO%20BLACK%20LOGO.png" height="60" style="max-width:100%;">
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20white%20text.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20black%20text.png">
|
||||
<img alt="Unsloth logo" src="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20black%20text.png" height="80" style="max-width:100%;">
|
||||
</picture></a>
|
||||
</h1>
|
||||
<h3 align="center" style="margin: 0; margin-top: 0;">
|
||||
Run and train AI models with a unified local interface.
|
||||
Unsloth Studio lets you run and train models locally.
|
||||
</h3>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-features">Features</a> •
|
||||
<a href="#-quickstart">Quickstart</a> •
|
||||
<a href="#-install">Quickstart</a> •
|
||||
<a href="#-free-notebooks">Notebooks</a> •
|
||||
<a href="https://unsloth.ai/docs">Documentation</a> •
|
||||
<a href="https://www.reddit.com/r/unsloth/">Reddit</a>
|
||||
<a href="https://unsloth.ai/docs">Documentation</a>
|
||||
</p>
|
||||
<a href="https://unsloth.ai/docs/new/studio">
|
||||
<img alt="unsloth studio ui homepage" src="https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/studio%20github%20landscape%20colab%20display.png" style="max-width: 100%; margin-bottom: 0;"></a>
|
||||
<br>
|
||||
<a href="https://unsloth.ai/docs/new/studio">
|
||||
<img alt="unsloth studio ui homepage" src="https://github.com/user-attachments/assets/53ae17a9-d975-44ef-9686-efb4ebd0454d" style="max-width: 100%; margin-bottom: 0;"></a>
|
||||
|
||||
Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning), [embedding](https://unsloth.ai/docs/new/embedding-finetuning), [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) models on Windows, Linux and macOS.
|
||||
## ⚡ Get started
|
||||
|
||||
#### macOS, Linux, WSL:
|
||||
```bash
|
||||
curl -fsSL https://unsloth.ai/install.sh | sh
|
||||
```
|
||||
#### Windows:
|
||||
```powershell
|
||||
irm https://unsloth.ai/install.ps1 | iex
|
||||
```
|
||||
#### Community:
|
||||
|
||||
- [Discord](https://discord.gg/unsloth)
|
||||
- [𝕏 (Twitter)](https://x.com/UnslothAI)
|
||||
- [Reddit](https://reddit.com/r/unsloth)
|
||||
|
||||
## ⭐ Features
|
||||
Unsloth provides several key features for both inference and training:
|
||||
Unsloth Studio (Beta) lets you run and train text, [audio](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning), [embedding](https://unsloth.ai/docs/new/embedding-finetuning), [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) models on Windows, Linux and macOS.
|
||||
|
||||
### Inference
|
||||
* **Search + download + run models** including GGUF, LoRA adapters, safetensors
|
||||
* **Export models**: [Save or export](https://unsloth.ai/docs/new/studio/export) models to GGUF, 16-bit safetensors and other formats.
|
||||
* **Tool calling**: Support for [self-healing tool calling](https://unsloth.ai/docs/new/studio/chat#auto-healing-tool-calling) and web search
|
||||
* **[Code execution](https://unsloth.ai/docs/new/studio/chat#code-execution)**: lets LLMs test code in Claude artifacts and sandbox environments
|
||||
* [Auto-tune inference parameters](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates.
|
||||
* **[API inference endpoint](https://unsloth.ai/docs/basics/api)**: Deploy and run local LLMs in Claude Code, Codex tools with Unsloth
|
||||
* [Auto set inference settings](https://unsloth.ai/docs/new/studio/chat#auto-parameter-tuning) and customize chat templates.
|
||||
* We work directly with teams behind [gpt-oss](https://docs.unsloth.ai/new/gpt-oss-how-to-run-and-fine-tune#unsloth-fixes-for-gpt-oss), [Qwen3](https://www.reddit.com/r/LocalLLaMA/comments/1kaodxu/qwen3_unsloth_dynamic_ggufs_128k_context_bug_fixes/), [Llama 4](https://github.com/ggml-org/llama.cpp/pull/12889), [Mistral](models/tutorials/devstral-how-to-run-and-fine-tune.md), [Gemma 1-3](https://news.ycombinator.com/item?id=39671146), and [Phi-4](https://unsloth.ai/blog/phi4), where we’ve fixed bugs that improve model accuracy.
|
||||
* Upload images, audio, PDFs, code, DOCX and more file types to chat with.
|
||||
### Training
|
||||
|
|
@ -40,7 +56,7 @@ Unsloth provides several key features for both inference and training:
|
|||
* **Observability**: Monitor training live, track loss and GPU usage and customize graphs.
|
||||
* [Multi-GPU](https://unsloth.ai/docs/basics/multi-gpu-training-with-unsloth) training is supported, with major improvements coming soon.
|
||||
|
||||
## ⚡ Quickstart
|
||||
## 📥 Install
|
||||
Unsloth can be used in two ways: through **[Unsloth Studio](https://unsloth.ai/docs/new/studio/)**, the web UI, or through **Unsloth Core**, the code-based version. Each has different requirements.
|
||||
|
||||
### Unsloth Studio (web UI)
|
||||
|
|
@ -64,8 +80,9 @@ irm https://unsloth.ai/install.ps1 | iex
|
|||
|
||||
#### Launch
|
||||
```bash
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
> For cloud VMs or LAN access, add `-H 0.0.0.0` to bind on all interfaces.
|
||||
|
||||
#### Update
|
||||
To update, use the same install commands as above. Or run (does not work on Windows):
|
||||
|
|
@ -109,18 +126,19 @@ You can use the same Docker image as Unsloth Studio.
|
|||
For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth). <br>
|
||||
To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel).
|
||||
|
||||
## ✨ Free Notebooks
|
||||
## 📒 Free Notebooks
|
||||
|
||||
Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model.
|
||||
Train for free with our notebooks. You can use our new [free Unsloth Studio notebook](https://colab.research.google.com/github/unslothai/unsloth/blob/main/studio/Unsloth_Studio_Colab.ipynb) to run and train models for free in a web UI.
|
||||
Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model.
|
||||
|
||||
| Model | Free Notebooks | Performance | Memory use |
|
||||
|-----------|---------|--------|----------|
|
||||
| **Gemma 4 (E2B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma4_(E2B)-Vision.ipynb) | 1.5x faster | 50% less |
|
||||
| **Qwen3.5 (4B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision.ipynb) | 1.5x faster | 60% less |
|
||||
| **gpt-oss (20B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-Fine-tuning.ipynb) | 2x faster | 70% less |
|
||||
| **Qwen3.5 GSPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision_GRPO.ipynb) | 2x faster | 70% less |
|
||||
| **gpt-oss (20B): GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-GRPO.ipynb) | 2x faster | 80% less |
|
||||
| **Qwen3: Advanced GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_(4B)-GRPO.ipynb) | 2x faster | 70% less |
|
||||
| **Gemma 3 (4B) Vision** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma3_(4B)-Vision.ipynb) | 1.7x faster | 60% less |
|
||||
| **embeddinggemma (300M)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/EmbeddingGemma_(300M).ipynb) | 2x faster | 20% less |
|
||||
| **Mistral Ministral 3 (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Ministral_3_VL_(3B)_Vision.ipynb) | 1.5x faster | 60% less |
|
||||
| **Llama 3.1 (8B) Alpaca** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.1_(8B)-Alpaca.ipynb) | 2x faster | 70% less |
|
||||
|
|
@ -132,6 +150,9 @@ Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get-
|
|||
- See detailed documentation for Unsloth [here](https://unsloth.ai/docs)
|
||||
|
||||
## 🦥 Unsloth News
|
||||
- **API inference endpoint**: Deploy and run local LLMs in Claude Code, Codex tools. [Guide](https://unsloth.ai/docs/basics/api)
|
||||
- **Qwen3.6**: Qwen3.6-35B-A3B can now be trained and run in Unsloth Studio. [Blog](https://unsloth.ai/docs/models/qwen3.6)
|
||||
- **Gemma 4**: Run and train Google’s new models directly in Unsloth. [Blog](https://unsloth.ai/docs/models/gemma-4)
|
||||
- **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio)
|
||||
- **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune)
|
||||
- Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe)
|
||||
|
|
@ -140,7 +161,6 @@ Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get-
|
|||
- New RoPE & MLP **Triton Kernels** & **Padding Free + Packing**: 3x faster training & 30% less VRAM. [Blog](https://unsloth.ai/docs/new/3x-faster-training-packing)
|
||||
- **500K Context**: Training a 20B model with >500K context is now possible on an 80GB GPU. [Blog](https://unsloth.ai/docs/blog/500k-context-length-fine-tuning)
|
||||
- **FP8 & Vision RL**: You can now do FP8 & VLM GRPO on consumer GPUs. [FP8 Blog](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) • [Vision RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/vision-reinforcement-learning-vlm-rl)
|
||||
- **gpt-oss** by OpenAI: Read our [RL blog](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune/gpt-oss-reinforcement-learning), [Flex Attention](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune/long-context-gpt-oss-training) blog and [Guide](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune).
|
||||
|
||||
## 📥 Advanced Installation
|
||||
The below advanced instructions are for Unsloth Studio. For Unsloth Core advanced installation, [view our docs](https://unsloth.ai/docs/get-started/install/pip-install#advanced-pip-installation).
|
||||
|
|
@ -149,7 +169,7 @@ The below advanced instructions are for Unsloth Studio. For Unsloth Core advance
|
|||
git clone https://github.com/unslothai/unsloth
|
||||
cd unsloth
|
||||
./install.sh --local
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
Then to update :
|
||||
```bash
|
||||
|
|
@ -162,7 +182,7 @@ git clone https://github.com/unslothai/unsloth.git
|
|||
cd unsloth
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\install.ps1 --local
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
Then to update :
|
||||
```bash
|
||||
|
|
@ -175,11 +195,11 @@ git clone https://github.com/unslothai/unsloth
|
|||
cd unsloth
|
||||
git checkout nightly
|
||||
./install.sh --local
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
Then to launch every time:
|
||||
```bash
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
|
||||
#### Nightly: Windows:
|
||||
|
|
@ -190,11 +210,11 @@ cd unsloth
|
|||
git checkout nightly
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\install.ps1 --local
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
Then to launch every time:
|
||||
```bash
|
||||
unsloth studio -H 0.0.0.0 -p 8888
|
||||
unsloth studio -p 8888
|
||||
```
|
||||
|
||||
#### Uninstall
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
512
install.ps1
512
install.ps1
|
|
@ -8,15 +8,90 @@ function Install-UnslothStudio {
|
|||
$ErrorActionPreference = "Stop"
|
||||
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq "1")
|
||||
|
||||
# ── Tauri structured output ──
|
||||
function Write-TauriLog {
|
||||
param([string]$Tag, [string]$Message)
|
||||
if ($TauriMode) {
|
||||
Write-Host "[TAURI:$Tag] $Message"
|
||||
}
|
||||
}
|
||||
|
||||
function Format-TauriDiagBool {
|
||||
param([bool]$Value)
|
||||
if ($Value) { return "true" }
|
||||
return "false"
|
||||
}
|
||||
|
||||
function Get-TauriDiagArch {
|
||||
$arch = [string]$env:PROCESSOR_ARCHITECTURE
|
||||
if ([string]::IsNullOrWhiteSpace($arch)) {
|
||||
try { $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $arch = "unknown" }
|
||||
}
|
||||
$arch = $arch.ToLowerInvariant()
|
||||
switch ($arch) {
|
||||
"amd64" { return "x86_64" }
|
||||
"x64" { return "x86_64" }
|
||||
"arm64" { return "arm64" }
|
||||
"x86" { return "x86" }
|
||||
default { return ($arch -replace '[^a-z0-9_.-]', '_') }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-TauriTorchIndexFamily {
|
||||
param([string]$TorchIndexUrl)
|
||||
if ($SkipTorch) { return "none" }
|
||||
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return "none" }
|
||||
$leaf = ($TorchIndexUrl.TrimEnd('/') -split '/')[-1].ToLowerInvariant()
|
||||
if (@("cpu", "cu118", "cu124", "cu126", "cu128", "cu130") -contains $leaf) { return $leaf }
|
||||
if ($leaf -match '^rocm[0-9]+\.[0-9]+$') { return $leaf }
|
||||
return "auto"
|
||||
}
|
||||
|
||||
function Get-TauriGpuBranch {
|
||||
param([string]$TorchIndexFamily)
|
||||
if ($SkipTorch) { return "no_torch" }
|
||||
if ($TorchIndexFamily -like "cu*") { return "cuda" }
|
||||
if ($TorchIndexFamily -like "rocm*") { return "rocm" }
|
||||
if ($TorchIndexFamily -eq "cpu") { return "cpu" }
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function Write-TauriDiag {
|
||||
param(
|
||||
[string]$GpuBranch = "unknown",
|
||||
[string]$TorchIndexFamily = "none",
|
||||
[string]$PythonVersionForDiag = $PythonVersion
|
||||
)
|
||||
if ([string]::IsNullOrWhiteSpace($PythonVersionForDiag)) { $PythonVersionForDiag = "unknown" }
|
||||
Write-TauriLog "DIAG" "diag_schema=1 platform=windows arch=$(Get-TauriDiagArch) python_version=$($PythonVersionForDiag.ToLowerInvariant()) skip_torch=$(Format-TauriDiagBool $SkipTorch) mac_intel=false gpu_branch=$GpuBranch torch_index_family=$TorchIndexFamily"
|
||||
}
|
||||
|
||||
function Exit-InstallFailure {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Message,
|
||||
[int]$Code = 1
|
||||
)
|
||||
if ($Code -eq 0) { $Code = 1 }
|
||||
Write-TauriLog "ERROR" $Message
|
||||
if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) {
|
||||
Restore-StudioVenvRollback
|
||||
}
|
||||
if ($TauriMode) {
|
||||
exit $Code
|
||||
}
|
||||
}
|
||||
|
||||
# ── Parse flags ──
|
||||
$StudioLocalInstall = $false
|
||||
$PackageName = "unsloth"
|
||||
$RepoRoot = ""
|
||||
$TauriMode = $false
|
||||
$SkipTorch = $false
|
||||
$argList = $args
|
||||
for ($i = 0; $i -lt $argList.Count; $i++) {
|
||||
switch ($argList[$i]) {
|
||||
"--local" { $StudioLocalInstall = $true }
|
||||
"--tauri" { $TauriMode = $true }
|
||||
"--no-torch" { $SkipTorch = $true }
|
||||
"--verbose" { $script:UnslothVerbose = $true }
|
||||
"-v" { $script:UnslothVerbose = $true }
|
||||
|
|
@ -24,7 +99,7 @@ function Install-UnslothStudio {
|
|||
$i++
|
||||
if ($i -ge $argList.Count) {
|
||||
Write-Host "[ERROR] --package requires an argument." -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "--package requires an argument.")
|
||||
}
|
||||
$PackageName = $argList[$i]
|
||||
}
|
||||
|
|
@ -40,10 +115,16 @@ function Install-UnslothStudio {
|
|||
$RepoRoot = (Resolve-Path (Split-Path -Parent $PSCommandPath)).Path
|
||||
if (-not (Test-Path (Join-Path $RepoRoot "pyproject.toml"))) {
|
||||
Write-Host "[ERROR] --local must be run from the unsloth repo root (pyproject.toml not found at $RepoRoot)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "--local must be run from the unsloth repo root")
|
||||
}
|
||||
}
|
||||
|
||||
# Validate --package to prevent injection into shell/Python commands
|
||||
if ($PackageName -notmatch '^[a-zA-Z0-9][a-zA-Z0-9._-]*$') {
|
||||
Write-Host "[ERROR] --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "--package name contains invalid characters")
|
||||
}
|
||||
|
||||
$PythonVersion = "3.13"
|
||||
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
$VenvDir = Join-Path $StudioHome "unsloth_studio"
|
||||
|
|
@ -100,22 +181,115 @@ function Install-UnslothStudio {
|
|||
Write-Host ""
|
||||
|
||||
# ── Helper: refresh PATH from registry (deduplicating entries) ──
|
||||
# Merge order: venv Scripts (if active) > Machine > User > current $env:Path.
|
||||
# Dedup compares both raw and expanded forms (%VAR% vs literal).
|
||||
function Refresh-SessionPath {
|
||||
$machine = [System.Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
$user = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
$merged = "$machine;$user;$env:Path"
|
||||
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV "Scripts" } else { $null }
|
||||
$sources = @()
|
||||
if ($venvScripts) { $sources += $venvScripts }
|
||||
$sources += @($machine, $user, $env:Path)
|
||||
$merged = ($sources | Where-Object { $_ }) -join ";"
|
||||
$seen = @{}
|
||||
$unique = @()
|
||||
$unique = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($p in $merged -split ";") {
|
||||
$key = $p.TrimEnd("\").ToLowerInvariant()
|
||||
if ($key -and -not $seen.ContainsKey($key)) {
|
||||
$seen[$key] = $true
|
||||
$unique += $p
|
||||
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
||||
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
||||
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
|
||||
$seen[$rawKey] = $true
|
||||
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
|
||||
$unique.Add($p)
|
||||
}
|
||||
}
|
||||
$env:Path = $unique -join ";"
|
||||
}
|
||||
|
||||
# ── Helper: safely add a directory to the persistent User PATH ──
|
||||
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
|
||||
# Append (default) keeps existing tools first; Prepend for must-win entries.
|
||||
function Add-ToUserPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Directory,
|
||||
[ValidateSet('Append','Prepend')]
|
||||
[string]$Position = 'Append'
|
||||
)
|
||||
try {
|
||||
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
||||
try {
|
||||
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
|
||||
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
$kept = New-Object System.Collections.Generic.List[string]
|
||||
$matchIndices = New-Object System.Collections.Generic.List[int]
|
||||
for ($i = 0; $i -lt $entries.Count; $i++) {
|
||||
$stripped = $entries[$i].Trim().Trim('"')
|
||||
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
|
||||
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
|
||||
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
|
||||
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
|
||||
if ($isMatch) {
|
||||
$matchIndices.Add($i)
|
||||
continue
|
||||
}
|
||||
$kept.Add($entries[$i])
|
||||
}
|
||||
$alreadyPresent = $matchIndices.Count -gt 0
|
||||
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
|
||||
return $false
|
||||
}
|
||||
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
|
||||
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
|
||||
return $false
|
||||
}
|
||||
# One-time backup under HKCU\Software\Unsloth\PathBackup
|
||||
if ($rawPath) {
|
||||
try {
|
||||
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
|
||||
try {
|
||||
$existingBackup = $backupKey.GetValue('PathBackup', $null)
|
||||
if (-not $existingBackup) {
|
||||
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
}
|
||||
} finally {
|
||||
$backupKey.Close()
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (-not $rawPath) {
|
||||
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
|
||||
}
|
||||
$newPath = if ($rawPath) {
|
||||
if ($Position -eq 'Prepend') {
|
||||
(@($Directory) + $kept) -join ';'
|
||||
} else {
|
||||
($kept + @($Directory)) -join ';'
|
||||
}
|
||||
} else {
|
||||
$Directory
|
||||
}
|
||||
if ($newPath -ceq $rawPath) { # no actual change
|
||||
return $false
|
||||
}
|
||||
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
|
||||
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
|
||||
try {
|
||||
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
||||
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
||||
} catch { }
|
||||
return $true
|
||||
} finally {
|
||||
$regKey.Close()
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function step {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Label,
|
||||
|
|
@ -378,7 +552,7 @@ try {
|
|||
} catch {}
|
||||
exit 1
|
||||
}
|
||||
`$studioCommand = '& "' + `$studioExe + '" studio -H 0.0.0.0 -p ' + `$launchPort
|
||||
`$studioCommand = '& "' + `$studioExe + '" studio -p ' + `$launchPort
|
||||
`$launchArgs = @(
|
||||
'-NoExit',
|
||||
'-NoProfile',
|
||||
|
|
@ -516,11 +690,12 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
|
||||
# ── Check winget ──
|
||||
Write-TauriLog "STEP" "Checking system dependencies"
|
||||
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
||||
step "winget" "not available" "Red"
|
||||
substep "Install it from https://aka.ms/getwinget" "Yellow"
|
||||
substep "or install Python $PythonVersion and uv manually, then re-run." "Yellow"
|
||||
return
|
||||
return (Exit-InstallFailure "winget is not available")
|
||||
}
|
||||
|
||||
# ── Helper: detect a working Python 3.11-3.13 on the system ──
|
||||
|
|
@ -595,6 +770,7 @@ shell.Run cmd, 0, False
|
|||
|
||||
# ── Install Python if no compatible version (3.11-3.13) found ──
|
||||
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
|
||||
Write-TauriLog "STEP" "Installing Python"
|
||||
$DetectedPython = Find-CompatiblePython
|
||||
if ($DetectedPython) {
|
||||
step "python" "Python $($DetectedPython.Version) already installed"
|
||||
|
|
@ -638,11 +814,17 @@ shell.Run cmd, 0, False
|
|||
Write-Host " Please install Python $PythonVersion manually from https://www.python.org/downloads/" -ForegroundColor Yellow
|
||||
Write-Host " Make sure to check 'Add Python to PATH' during installation." -ForegroundColor Yellow
|
||||
Write-Host " Then re-run this installer." -ForegroundColor Yellow
|
||||
return
|
||||
return (Exit-InstallFailure "Python installation failed")
|
||||
}
|
||||
}
|
||||
$DiagPythonVersion = $PythonVersion
|
||||
if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version }
|
||||
$InitialGpuBranch = "unknown"
|
||||
if ($SkipTorch) { $InitialGpuBranch = "no_torch" }
|
||||
Write-TauriDiag -GpuBranch $InitialGpuBranch -TorchIndexFamily "none" -PythonVersionForDiag $DiagPythonVersion
|
||||
|
||||
# ── Install uv if not present ──
|
||||
Write-TauriLog "STEP" "Installing uv package manager"
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
substep "installing uv package manager..."
|
||||
$prevEAP = $ErrorActionPreference
|
||||
|
|
@ -653,7 +835,7 @@ shell.Run cmd, 0, False
|
|||
# Fallback: if winget didn't put uv on PATH, try the PowerShell installer
|
||||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
substep "trying alternative uv installer..." "Yellow"
|
||||
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1")
|
||||
Refresh-SessionPath
|
||||
}
|
||||
}
|
||||
|
|
@ -661,23 +843,81 @@ shell.Run cmd, 0, False
|
|||
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
||||
step "uv" "could not be installed" "Red"
|
||||
substep "Install it from https://docs.astral.sh/uv/" "Yellow"
|
||||
return
|
||||
return (Exit-InstallFailure "uv could not be installed")
|
||||
}
|
||||
|
||||
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
|
||||
# Pass the resolved executable path to uv so it does not re-resolve
|
||||
# a version string back to a conda interpreter.
|
||||
Write-TauriLog "STEP" "Creating virtual environment"
|
||||
if (-not (Test-Path $StudioHome)) {
|
||||
New-Item -ItemType Directory -Path $StudioHome -Force | Out-Null
|
||||
}
|
||||
|
||||
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
|
||||
$_Migrated = $false
|
||||
$script:StudioVenvRollbackDir = $null
|
||||
$script:StudioVenvRollbackTarget = $VenvDir
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
|
||||
function Start-StudioVenvRollback {
|
||||
param([Parameter(Mandatory = $true)][string]$ExistingDir)
|
||||
$stamp = Get-Date -Format "yyyyMMddHHmmss"
|
||||
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID"
|
||||
$suffix = 0
|
||||
while (Test-Path $candidate) {
|
||||
$suffix++
|
||||
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID.$suffix"
|
||||
}
|
||||
Move-Item -Path $ExistingDir -Destination $candidate -ErrorAction Stop
|
||||
$script:StudioVenvRollbackDir = $candidate
|
||||
$script:StudioVenvRollbackTarget = $ExistingDir
|
||||
$script:StudioVenvRollbackActive = $true
|
||||
substep "previous environment preserved for rollback"
|
||||
}
|
||||
|
||||
function Restore-StudioVenvRollback {
|
||||
if (-not $script:StudioVenvRollbackActive) { return }
|
||||
$backup = $script:StudioVenvRollbackDir
|
||||
$target = $script:StudioVenvRollbackTarget
|
||||
if (-not $backup -or -not (Test-Path $backup)) {
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
return
|
||||
}
|
||||
substep "restoring previous environment after failed install..." "Yellow"
|
||||
try {
|
||||
if (Test-Path $target) {
|
||||
Remove-Item -Recurse -Force $target -ErrorAction SilentlyContinue
|
||||
}
|
||||
Move-Item -Path $backup -Destination $target -Force -ErrorAction Stop
|
||||
substep "restored previous environment"
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
$script:StudioVenvRollbackDir = $null
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not restore previous environment from $backup to $target" -ForegroundColor Yellow
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
function Complete-StudioVenvRollback {
|
||||
if (-not $script:StudioVenvRollbackActive) { return }
|
||||
$backup = $script:StudioVenvRollbackDir
|
||||
if ($backup -and (Test-Path $backup)) {
|
||||
Remove-Item -Recurse -Force $backup -ErrorAction SilentlyContinue
|
||||
}
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
$script:StudioVenvRollbackDir = $null
|
||||
}
|
||||
|
||||
if (Test-Path $VenvPython) {
|
||||
# New layout already exists -- nuke for fresh install
|
||||
substep "removing existing environment for fresh install..."
|
||||
Remove-Item -Recurse -Force $VenvDir
|
||||
# New layout already exists -- replace only after preserving rollback copy.
|
||||
substep "preserving existing environment for rollback..."
|
||||
try {
|
||||
Start-StudioVenvRollback -ExistingDir $VenvDir
|
||||
} catch {
|
||||
Write-Host "[ERROR] Could not prepare existing environment for reinstall: $($_.Exception.Message)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Could not prepare existing environment for reinstall")
|
||||
}
|
||||
} elseif (Test-Path (Join-Path $StudioHome ".venv\Scripts\python.exe")) {
|
||||
# Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating
|
||||
$OldVenv = Join-Path $StudioHome ".venv"
|
||||
|
|
@ -686,18 +926,23 @@ shell.Run cmd, 0, False
|
|||
$prevEAP2 = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
try {
|
||||
& $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null
|
||||
$torchOk = ($LASTEXITCODE -eq 0)
|
||||
} catch { $torchOk = $false }
|
||||
if ($SkipTorch) {
|
||||
& $OldPy -c "import sys; print(sys.executable)" 2>$null | Out-Null
|
||||
} else {
|
||||
& $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null
|
||||
}
|
||||
$legacyOk = ($LASTEXITCODE -eq 0)
|
||||
} catch { $legacyOk = $false }
|
||||
$ErrorActionPreference = $prevEAP2
|
||||
if ($torchOk) {
|
||||
if ($legacyOk) {
|
||||
substep "legacy environment is healthy -- migrating..."
|
||||
Move-Item -Path $OldVenv -Destination $VenvDir -Force
|
||||
substep "moved .venv -> unsloth_studio"
|
||||
$_Migrated = $true
|
||||
} else {
|
||||
substep "legacy environment failed validation -- creating fresh environment" "Yellow"
|
||||
Remove-Item -Recurse -Force $OldVenv -ErrorAction SilentlyContinue
|
||||
$invalidVenv = Join-Path $StudioHome (".venv.invalid.{0}.{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), $PID)
|
||||
Move-Item -Path $OldVenv -Destination $invalidVenv -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} elseif (Test-Path (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe")) {
|
||||
# CWD-relative venv from old install.ps1 -- migrate to absolute path
|
||||
|
|
@ -714,7 +959,7 @@ shell.Run cmd, 0, False
|
|||
$venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" }
|
||||
if ($venvExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit)
|
||||
}
|
||||
} else {
|
||||
step "venv" "using migrated environment"
|
||||
|
|
@ -754,7 +999,7 @@ shell.Run cmd, 0, False
|
|||
# ── Choose the correct PyTorch index URL based on driver CUDA version ──
|
||||
# Mirrors Get-PytorchCudaTag in setup.ps1.
|
||||
function Get-TorchIndexUrl {
|
||||
$baseUrl = "https://download.pytorch.org/whl"
|
||||
$baseUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
|
||||
if (-not $NvidiaSmiExe) { return "$baseUrl/cpu" }
|
||||
try {
|
||||
$output = & $NvidiaSmiExe 2>&1 | Out-String
|
||||
|
|
@ -772,6 +1017,9 @@ shell.Run cmd, 0, False
|
|||
return "$baseUrl/cu126"
|
||||
}
|
||||
$TorchIndexUrl = Get-TorchIndexUrl
|
||||
$TorchIndexFamily = Get-TauriTorchIndexFamily $TorchIndexUrl
|
||||
$GpuBranch = Get-TauriGpuBranch $TorchIndexFamily
|
||||
Write-TauriDiag -GpuBranch $GpuBranch -TorchIndexFamily $TorchIndexFamily -PythonVersionForDiag $DetectedPython.Version
|
||||
|
||||
# ── Print CPU-only hint when no GPU detected ──
|
||||
if (-not $SkipTorch -and $TorchIndexUrl -like "*/cpu") {
|
||||
|
|
@ -815,11 +1063,12 @@ shell.Run cmd, 0, False
|
|||
if ($_Migrated) {
|
||||
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
|
||||
# in the new venv location, while preserving existing torch/CUDA
|
||||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "upgrading unsloth in migrated environment..."
|
||||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.1" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
$NoTorchReq = Find-NoTorchRuntimeFile
|
||||
if ($NoTorchReq) {
|
||||
|
|
@ -827,37 +1076,45 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.1" unsloth-zoo }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
}
|
||||
if ($StudioLocalInstall) {
|
||||
substep "overlaying local repo (editable)..."
|
||||
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
||||
if ($overlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
||||
}
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
||||
if ($zooOverlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
||||
}
|
||||
}
|
||||
} elseif ($TorchIndexUrl) {
|
||||
if ($SkipTorch) {
|
||||
substep "skipping PyTorch (--no-torch flag set)." "Yellow"
|
||||
} else {
|
||||
Write-TauriLog "STEP" "Installing PyTorch"
|
||||
substep "installing PyTorch ($TorchIndexUrl)..."
|
||||
$torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
|
||||
if ($torchInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
||||
}
|
||||
}
|
||||
|
||||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($SkipTorch) {
|
||||
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
||||
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.18" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.1" unsloth-zoo }
|
||||
if ($baseInstallExit -eq 0) {
|
||||
$NoTorchReq = Find-NoTorchRuntimeFile
|
||||
if ($NoTorchReq) {
|
||||
|
|
@ -865,13 +1122,13 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
} elseif ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.18" unsloth-zoo }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.1" unsloth-zoo }
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
||||
}
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
}
|
||||
|
||||
if ($StudioLocalInstall) {
|
||||
|
|
@ -879,29 +1136,85 @@ shell.Run cmd, 0, False
|
|||
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
||||
if ($overlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
||||
}
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
||||
if ($zooOverlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
|
||||
Write-TauriLog "STEP" "Installing unsloth"
|
||||
substep "installing unsloth (this may take a few minutes)..."
|
||||
if ($StudioLocalInstall) {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.18" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.1" --torch-backend=auto }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
}
|
||||
substep "overlaying local repo (editable)..."
|
||||
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
||||
if ($overlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
||||
}
|
||||
substep "overlaying unsloth-zoo from git main..."
|
||||
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
||||
if ($zooOverlayExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
||||
}
|
||||
} else {
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "$PackageName" --torch-backend=auto }
|
||||
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" }
|
||||
if ($baseInstallExit -ne 0) {
|
||||
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
||||
return
|
||||
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Overlay Tauri-bundled studio fixes that may be ahead of PyPI. Skipped
|
||||
# for --local: the editable install above already makes _PACKAGE_ROOT in
|
||||
# unsloth_cli/commands/studio.py resolve to the repo (PEP 660 __file__).
|
||||
# Source paths match the Tauri bundle layout in studio/src-tauri/tauri.conf.json,
|
||||
# which bundles install_python_stack.py at the bundle root next to install.ps1.
|
||||
if ($TauriMode) {
|
||||
$rawPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.ScriptName }
|
||||
if ($rawPath) {
|
||||
# Strip leading \\?\ extended-length prefix if the launcher passed one.
|
||||
$scriptDir = Split-Path -Parent ($rawPath -replace '^\\\\\?\\', '')
|
||||
$overlayMap = [ordered]@{
|
||||
"install_python_stack.py" = "Lib\site-packages\studio\install_python_stack.py"
|
||||
}
|
||||
foreach ($rel in $overlayMap.Keys) {
|
||||
$src = Join-Path $scriptDir $rel
|
||||
$dst = Join-Path $VenvDir $overlayMap[$rel]
|
||||
if (-not (Test-Path $src)) { continue }
|
||||
$dstParent = Split-Path -Parent $dst
|
||||
if (-not (Test-Path $dstParent)) {
|
||||
Write-Host "[WARN] Overlay target dir missing: $dstParent; studio setup may use stale bundled file" -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (-not (Test-Path $dst)) {
|
||||
# Backfill: target file missing but parent dir exists.
|
||||
Copy-Item $src $dst -Force
|
||||
substep ("backfilled bundled " + (Split-Path -Leaf $rel))
|
||||
} else {
|
||||
# Hash-compare so re-runs are no-ops when files already match.
|
||||
$srcHash = (Get-FileHash $src -Algorithm SHA256).Hash
|
||||
$dstHash = (Get-FileHash $dst -Algorithm SHA256).Hash
|
||||
if ($srcHash -ne $dstHash) {
|
||||
Copy-Item $src $dst -Force
|
||||
substep ("applied bundled " + (Split-Path -Leaf $rel))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not overlay $($rel): $($_.Exception.Message); studio setup may use stale bundled file" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -909,6 +1222,7 @@ shell.Run cmd, 0, False
|
|||
# ── Run studio setup ──
|
||||
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
|
||||
# CUDA Toolkit, Node.js, and other dependencies automatically via winget.
|
||||
Write-TauriLog "STEP" "Running studio setup"
|
||||
step "setup" "running unsloth studio setup..."
|
||||
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
|
||||
if (-not (Test-Path $UnslothExe)) {
|
||||
|
|
@ -916,12 +1230,14 @@ shell.Run cmd, 0, False
|
|||
Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow
|
||||
Write-Host " This usually means an older unsloth version was installed that does not include the Studio CLI." -ForegroundColor Yellow
|
||||
Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow
|
||||
return
|
||||
return (Exit-InstallFailure "unsloth CLI was not installed correctly")
|
||||
}
|
||||
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
|
||||
$env:SKIP_STUDIO_BASE = "1"
|
||||
$env:STUDIO_PACKAGE_NAME = $PackageName
|
||||
$env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" }
|
||||
# Tauri desktop app bundles its own frontend — skip Node/npm/frontend build
|
||||
$env:SKIP_STUDIO_FRONTEND = if ($TauriMode) { "1" } else { "0" }
|
||||
# Always set STUDIO_LOCAL_INSTALL explicitly to avoid stale values from
|
||||
# a previous --local run in the same PowerShell session.
|
||||
if ($StudioLocalInstall) {
|
||||
|
|
@ -936,37 +1252,117 @@ shell.Run cmd, 0, False
|
|||
# and bypass the fast-path version check from PR #4667.
|
||||
$studioArgs = @('studio', 'setup')
|
||||
if ($script:UnslothVerbose) { $studioArgs += '--verbose' }
|
||||
& $UnslothExe @studioArgs
|
||||
$setupExit = $LASTEXITCODE
|
||||
$env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1"
|
||||
try {
|
||||
& $UnslothExe @studioArgs
|
||||
$setupExit = $LASTEXITCODE
|
||||
} finally {
|
||||
Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($setupExit -ne 0) {
|
||||
Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "unsloth studio setup failed (exit code $setupExit)" $setupExit)
|
||||
}
|
||||
|
||||
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
|
||||
# We do NOT add the venv Scripts dir to PATH (it also holds python.exe
|
||||
# and pip.exe, which would hijack the user's system interpreter).
|
||||
# Hardlink preferred; falls back to copy if cross-volume or non-NTFS.
|
||||
#
|
||||
# Remove the legacy venv Scripts PATH entry that older installers wrote.
|
||||
$LegacyScriptsDir = Join-Path $VenvDir "Scripts"
|
||||
try {
|
||||
$legacyKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
||||
try {
|
||||
$rawPath = $legacyKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
if ($rawPath) {
|
||||
[string[]]$pathEntries = $rawPath -split ';'
|
||||
$normalLegacy = $LegacyScriptsDir.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
$expNormalLegacy = [Environment]::ExpandEnvironmentVariables($LegacyScriptsDir).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
$filtered = @($pathEntries | Where-Object {
|
||||
$stripped = $_.Trim().Trim('"')
|
||||
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
|
||||
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
|
||||
($rawNorm -ne $normalLegacy -and $rawNorm -ne $expNormalLegacy) -and
|
||||
($expNorm -ne $normalLegacy -and $expNorm -ne $expNormalLegacy)
|
||||
})
|
||||
$cleanedPath = $filtered -join ';'
|
||||
if ($cleanedPath -ne $rawPath) {
|
||||
$legacyKey.SetValue('Path', $cleanedPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
try {
|
||||
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
||||
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$legacyKey.Close()
|
||||
}
|
||||
} catch { }
|
||||
$ShimDir = Join-Path $StudioHome "bin"
|
||||
New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null
|
||||
$ShimExe = Join-Path $ShimDir "unsloth.exe"
|
||||
# try/catch: if unsloth.exe is locked (Studio running), keep the old shim.
|
||||
$shimUpdated = $false
|
||||
try {
|
||||
if (Test-Path $ShimExe) { Remove-Item $ShimExe -Force -ErrorAction Stop }
|
||||
try {
|
||||
New-Item -ItemType HardLink -Path $ShimExe -Target $UnslothExe -ErrorAction Stop | Out-Null
|
||||
} catch {
|
||||
Copy-Item -Path $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop # fallback: copy
|
||||
}
|
||||
$shimUpdated = $true
|
||||
} catch {
|
||||
if (Test-Path $ShimExe) {
|
||||
Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow
|
||||
Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow
|
||||
Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
|
||||
Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
Write-Host " Launch unsloth studio directly via '$UnslothExe' until the next successful install." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
# Only add to PATH when the launcher actually exists on disk.
|
||||
$pathAdded = $false
|
||||
if (Test-Path $ShimExe) {
|
||||
$pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend'
|
||||
}
|
||||
if ($shimUpdated -and $pathAdded) {
|
||||
step "path" "added unsloth launcher to PATH"
|
||||
}
|
||||
Refresh-SessionPath # sync current session with registry
|
||||
Complete-StudioVenvRollback
|
||||
|
||||
# ── Tauri mode: done, skip shortcuts and auto-launch ──
|
||||
if ($TauriMode) {
|
||||
Write-TauriLog "DONE" ""
|
||||
return
|
||||
}
|
||||
|
||||
New-StudioShortcuts -UnslothExePath $UnslothExe
|
||||
|
||||
# ── Add venv Scripts dir to User PATH so `unsloth studio` works from any terminal ──
|
||||
$ScriptsDir = Join-Path $VenvDir "Scripts"
|
||||
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
if (-not $UserPath -or $UserPath -notlike "*$ScriptsDir*") {
|
||||
if ($UserPath) {
|
||||
[System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir;$UserPath", "User")
|
||||
} else {
|
||||
[System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir", "User")
|
||||
}
|
||||
Refresh-SessionPath
|
||||
step "path" "added unsloth to PATH"
|
||||
}
|
||||
|
||||
# Launch studio automatically in interactive terminals;
|
||||
# in non-interactive environments (CI, Docker) just print instructions.
|
||||
# In interactive terminals, ask the user before starting Studio.
|
||||
# In non-interactive environments (CI, Docker) just print instructions.
|
||||
$IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
|
||||
if ($IsInteractive) {
|
||||
& $UnslothExe studio -H 0.0.0.0 -p 8888
|
||||
Write-Host ""
|
||||
$reply = Read-Host " Start Unsloth Studio now? [Y/n]"
|
||||
if ([string]::IsNullOrWhiteSpace($reply) -or $reply -match '^[Yy]') {
|
||||
& $UnslothExe studio -p 8888
|
||||
} else {
|
||||
step "launch" "to start later, run:"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
Write-Host ""
|
||||
}
|
||||
} else {
|
||||
step "launch" "manual commands:"
|
||||
substep "& `"$VenvDir\Scripts\Activate.ps1`""
|
||||
substep "unsloth studio -H 0.0.0.0 -p 8888"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
Write-Host ""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
768
install.sh
768
install.sh
File diff suppressed because it is too large
Load diff
|
|
@ -53,6 +53,7 @@ studio = [
|
|||
"frontend/*.yaml",
|
||||
"frontend/.git*",
|
||||
"backend/requirements/**/*",
|
||||
"backend/plugins/**/*",
|
||||
"backend/core/data_recipe/oxc-validator/*.json",
|
||||
"backend/core/data_recipe/oxc-validator/*.mjs",
|
||||
]
|
||||
|
|
@ -82,13 +83,13 @@ huggingfacenotorch = [
|
|||
"huggingface_hub>=0.34.0",
|
||||
"hf_transfer",
|
||||
"diffusers",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.3.0",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
|
||||
"trl>=0.18.2,!=0.19.0,<=0.24.0",
|
||||
"sentence-transformers",
|
||||
]
|
||||
huggingface = [
|
||||
"unsloth[huggingfacenotorch]",
|
||||
"unsloth_zoo>=2026.3.7",
|
||||
"unsloth_zoo>=2026.5.1",
|
||||
"torchvision",
|
||||
"unsloth[triton]",
|
||||
]
|
||||
|
|
@ -578,10 +579,10 @@ colab-ampere-torch220 = [
|
|||
"flash-attn>=2.6.3 ; ('linux' in sys_platform)",
|
||||
]
|
||||
colab-new = [
|
||||
"unsloth_zoo>=2026.3.7",
|
||||
"unsloth_zoo>=2026.5.1",
|
||||
"packaging",
|
||||
"tyro",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.3.0",
|
||||
"transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.5.0",
|
||||
"datasets>=3.4.1,!=4.0.*,!=4.1.0,<4.4.0",
|
||||
"sentencepiece>=0.2.0",
|
||||
"tqdm",
|
||||
|
|
|
|||
169
scripts/install_gemma4_mlx.sh
Executable file
169
scripts/install_gemma4_mlx.sh
Executable file
|
|
@ -0,0 +1,169 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ============================================================
|
||||
# Gemma 4 MLX — One-command setup + inference
|
||||
#
|
||||
# Usage:
|
||||
# bash install_gemma4_mlx.sh [--venv-dir DIR]
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates a Python virtual environment
|
||||
# 2. Installs uv, mlx-vlm, transformers
|
||||
# ============================================================
|
||||
|
||||
# ── Output style (inspired by unsloth/install.sh) ─────────────
|
||||
RULE=""
|
||||
_rule_i=0
|
||||
while [ "$_rule_i" -lt 52 ]; do
|
||||
RULE="${RULE}─"
|
||||
_rule_i=$((_rule_i + 1))
|
||||
done
|
||||
|
||||
if [ -n "${NO_COLOR:-}" ]; then
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then
|
||||
_ESC="$(printf '\033')"
|
||||
C_TITLE="${_ESC}[38;5;117m"
|
||||
C_DIM="${_ESC}[38;5;245m"
|
||||
C_OK="${_ESC}[38;5;108m"
|
||||
C_WARN="${_ESC}[38;5;136m"
|
||||
C_ERR="${_ESC}[91m"
|
||||
C_RST="${_ESC}[0m"
|
||||
else
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
fi
|
||||
|
||||
step() { printf " ${C_DIM}%-18.18s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; }
|
||||
substep() { printf " ${C_DIM}%-18s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; }
|
||||
fail() { step "error" "$1" "$C_ERR"; exit 1; }
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────
|
||||
VENV_DIR=""
|
||||
_next_is_venv=false
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$_next_is_venv" = true ]; then
|
||||
VENV_DIR="$arg"
|
||||
_next_is_venv=false
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--venv-dir) _next_is_venv=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Default venv location
|
||||
if [ -z "$VENV_DIR" ]; then
|
||||
VENV_DIR="$HOME/.unsloth/unsloth_gemma4_mlx"
|
||||
fi
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "💎 Gemma 4 MLX Installer"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
|
||||
# ── Platform check ────────────────────────────────────────────
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
fail "MLX requires macOS with Apple Silicon. Detected: $(uname)"
|
||||
fi
|
||||
|
||||
_ARCH=$(uname -m)
|
||||
if [ "$_ARCH" != "arm64" ]; then
|
||||
step "warning" "Apple Silicon recommended (detected: $_ARCH)" "$C_WARN"
|
||||
fi
|
||||
|
||||
step "platform" "macOS ($_ARCH)"
|
||||
|
||||
# ── Detect Python ─────────────────────────────────────────────
|
||||
PYTHON=""
|
||||
for _candidate in python3.12 python3.11 python3.13 python3; do
|
||||
if command -v "$_candidate" >/dev/null 2>&1; then
|
||||
PYTHON="$_candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON" ]; then
|
||||
fail "Python 3 not found. Install via: brew install python@3.12"
|
||||
fi
|
||||
|
||||
_PY_VERSION=$("$PYTHON" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')")
|
||||
step "python" "$PYTHON ($_PY_VERSION)"
|
||||
|
||||
# ── Create virtual environment ────────────────────────────────
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
step "venv" "using existing environment"
|
||||
substep "$VENV_DIR"
|
||||
else
|
||||
step "venv" "creating virtual environment"
|
||||
substep "$VENV_DIR"
|
||||
mkdir -p "$(dirname "$VENV_DIR")"
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# ── Install uv ───────────────────────────────────────────────
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
step "uv" "installing uv package manager..."
|
||||
_uv_tmp=$(mktemp)
|
||||
curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp"
|
||||
sh "$_uv_tmp" </dev/null >/dev/null 2>&1
|
||||
rm -f "$_uv_tmp"
|
||||
if [ -f "$HOME/.local/bin/env" ]; then
|
||||
. "$HOME/.local/bin/env"
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
substep "done"
|
||||
else
|
||||
step "uv" "found $(uv --version 2>/dev/null || echo 'uv')"
|
||||
fi
|
||||
|
||||
_VENV_PY="$VENV_DIR/bin/python"
|
||||
|
||||
# ── Install dependencies ──────────────────────────────────────
|
||||
step "install" "installing mlx-vlm..."
|
||||
uv pip install --python "$_VENV_PY" -q mlx-vlm
|
||||
substep "done"
|
||||
|
||||
step "install" "installing transformers>=5.5.0..."
|
||||
if uv pip install --python "$_VENV_PY" -q "transformers>=5.5.0" 2>/dev/null; then
|
||||
substep "installed from PyPI"
|
||||
else
|
||||
substep "PyPI install failed (Python <3.10?), trying GitHub..."
|
||||
if uv pip install --python "$_VENV_PY" -q "git+https://github.com/huggingface/transformers.git@v5.5-release" 2>/dev/null; then
|
||||
substep "installed from huggingface/transformers v5.5-release"
|
||||
else
|
||||
step "warning" "could not install transformers>=5.5.0" "$C_WARN"
|
||||
substep "tried: PyPI, huggingface/transformers v5.5-release"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Verify installation ──────────────────────────────────────
|
||||
if "$_VENV_PY" -c "import mlx_vlm"; then
|
||||
substep "mlx-vlm verified"
|
||||
else
|
||||
fail "Installation verification failed."
|
||||
fi
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "Gemma 4 MLX installed!"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
step "available models" "unsloth/gemma-4-E2B-it-UD-MLX-4bit"
|
||||
substep "unsloth/gemma-4-E4B-it-UD-MLX-4bit"
|
||||
substep "unsloth/gemma-4-26b-a4b-it-UD-MLX-4bit"
|
||||
substep "unsloth/gemma-4-31b-it-UD-MLX-4bit"
|
||||
echo ""
|
||||
step "venv activate" "source ${VENV_DIR}/bin/activate"
|
||||
echo ""
|
||||
step "text chat" "python -m mlx_vlm.chat --model unsloth/gemma-4-E2B-it-UD-MLX-4bit"
|
||||
echo ""
|
||||
step "vision chat" "python -m mlx_vlm.chat --model unsloth/gemma-4-31b-it-UD-MLX-4bit"
|
||||
substep "Use /image path/to/image.jpg to load an image"
|
||||
echo ""
|
||||
step "gradio UI" "python -m mlx_vlm.chat_ui --model unsloth/gemma-4-31b-it-UD-MLX-4bit"
|
||||
echo ""
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
191
scripts/install_qwen3_6_mlx.sh
Normal file
191
scripts/install_qwen3_6_mlx.sh
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ============================================================
|
||||
# Qwen3.6 MLX — One-command setup + inference
|
||||
#
|
||||
# Usage:
|
||||
# bash install_qwen3_6_mlx.sh [--venv-dir DIR]
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates a Python virtual environment
|
||||
# 2. Installs uv, mlx-vlm, transformers, torch, torchvision
|
||||
# ============================================================
|
||||
|
||||
# ── Output style (inspired by unsloth/install.sh) ─────────────
|
||||
RULE=""
|
||||
_rule_i=0
|
||||
while [ "$_rule_i" -lt 52 ]; do
|
||||
RULE="${RULE}─"
|
||||
_rule_i=$((_rule_i + 1))
|
||||
done
|
||||
|
||||
if [ -n "${NO_COLOR:-}" ]; then
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then
|
||||
_ESC="$(printf '\033')"
|
||||
C_TITLE="${_ESC}[38;5;117m"
|
||||
C_DIM="${_ESC}[38;5;245m"
|
||||
C_OK="${_ESC}[38;5;108m"
|
||||
C_WARN="${_ESC}[38;5;136m"
|
||||
C_ERR="${_ESC}[91m"
|
||||
C_RST="${_ESC}[0m"
|
||||
else
|
||||
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
||||
fi
|
||||
|
||||
step() { printf " ${C_DIM}%-18.18s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; }
|
||||
substep() { printf " ${C_DIM}%-18s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; }
|
||||
fail() { step "error" "$1" "$C_ERR"; exit 1; }
|
||||
|
||||
# ── Parse flags ───────────────────────────────────────────────
|
||||
VENV_DIR=""
|
||||
_next_is_venv=false
|
||||
|
||||
for arg in "$@"; do
|
||||
if [ "$_next_is_venv" = true ]; then
|
||||
VENV_DIR="$arg"
|
||||
_next_is_venv=false
|
||||
continue
|
||||
fi
|
||||
case "$arg" in
|
||||
--venv-dir) _next_is_venv=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Default venv location
|
||||
if [ -z "$VENV_DIR" ]; then
|
||||
VENV_DIR="$HOME/.unsloth/unsloth_qwen3_6_mlx"
|
||||
fi
|
||||
|
||||
# ── Banner ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "Qwen3.6 MLX Installer"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
|
||||
# ── Platform check ────────────────────────────────────────────
|
||||
if [ "$(uname)" != "Darwin" ]; then
|
||||
fail "MLX requires macOS with Apple Silicon. Detected: $(uname)"
|
||||
fi
|
||||
|
||||
_ARCH=$(uname -m)
|
||||
if [ "$_ARCH" != "arm64" ]; then
|
||||
step "warning" "Apple Silicon recommended (detected: $_ARCH)" "$C_WARN"
|
||||
fi
|
||||
|
||||
step "platform" "macOS ($_ARCH)"
|
||||
|
||||
# ── Detect Python ─────────────────────────────────────────────
|
||||
PYTHON=""
|
||||
for _candidate in python3.12 python3.11 python3.13 python3; do
|
||||
if command -v "$_candidate" >/dev/null 2>&1; then
|
||||
PYTHON="$_candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$PYTHON" ]; then
|
||||
fail "Python 3 not found. Install via: brew install python@3.12"
|
||||
fi
|
||||
|
||||
_PY_VERSION=$("$PYTHON" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')")
|
||||
step "python" "$PYTHON ($_PY_VERSION)"
|
||||
|
||||
# ── Create virtual environment ────────────────────────────────
|
||||
if [ -x "$VENV_DIR/bin/python" ]; then
|
||||
step "venv" "using existing environment"
|
||||
substep "$VENV_DIR"
|
||||
else
|
||||
step "venv" "creating virtual environment"
|
||||
substep "$VENV_DIR"
|
||||
mkdir -p "$(dirname "$VENV_DIR")"
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# ── Install uv ───────────────────────────────────────────────
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
step "uv" "installing uv package manager..."
|
||||
_uv_tmp=$(mktemp)
|
||||
curl -LsSf "https://astral.sh/uv/install.sh" -o "$_uv_tmp"
|
||||
sh "$_uv_tmp" </dev/null
|
||||
rm -f "$_uv_tmp"
|
||||
if [ -f "$HOME/.local/bin/env" ]; then
|
||||
. "$HOME/.local/bin/env"
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
substep "done"
|
||||
else
|
||||
step "uv" "found $(uv --version 2>/dev/null || echo 'uv')"
|
||||
fi
|
||||
|
||||
_VENV_PY="$VENV_DIR/bin/python"
|
||||
|
||||
# ── Install dependencies ──────────────────────────────────────
|
||||
step "install" "installing mlx-vlm..."
|
||||
uv pip install --python "$_VENV_PY" -q mlx-vlm
|
||||
substep "done"
|
||||
|
||||
step "install" "installing transformers>=5.2.0..."
|
||||
if uv pip install --python "$_VENV_PY" -q "transformers>=5.2.0"; then
|
||||
substep "installed from PyPI"
|
||||
else
|
||||
substep "PyPI install failed, trying GitHub..."
|
||||
if uv pip install --python "$_VENV_PY" -q "git+https://github.com/huggingface/transformers.git"; then
|
||||
substep "installed from huggingface/transformers main"
|
||||
else
|
||||
fail "Could not install transformers>=5.2.0 (required for Qwen3.5/3.6 model support). Please check your Python version (>=3.10 required) and network connection, then try again."
|
||||
fi
|
||||
fi
|
||||
|
||||
step "install" "installing torch + torchvision (needed for Qwen3 VL processor)..."
|
||||
uv pip install --python "$_VENV_PY" -q torch torchvision
|
||||
substep "done"
|
||||
|
||||
# ── Verify installation ──────────────────────────────────────
|
||||
if "$_VENV_PY" -c "import mlx_vlm; import torch; import torchvision; import transformers"; then
|
||||
substep "mlx-vlm + torch + transformers verified"
|
||||
else
|
||||
fail "Installation verification failed. Please ensure Python >=3.10 and try again."
|
||||
fi
|
||||
|
||||
# ── Apply patches for multi-turn image chat ──────────────────
|
||||
_PATCH_BASE="https://raw.githubusercontent.com/unslothai/unsloth/refs/heads/fix/ui-fix/unsloth/models/patches/mlx_vlm_qwen3_5"
|
||||
_SITE_PKGS=$("$_VENV_PY" -c "import site; print(site.getsitepackages()[0])")
|
||||
|
||||
step "patch" "fixing multi-turn image chat..."
|
||||
|
||||
if curl -sSLf "${_PATCH_BASE}/qwen3_5.py" -o "${_SITE_PKGS}/mlx_vlm/models/qwen3_5/qwen3_5.py"; then
|
||||
substep "patched qwen3_5.py (MRoPE position reset)"
|
||||
else
|
||||
step "warning" "failed to download qwen3_5.py patch — multi-turn image chat may not work" "$C_WARN"
|
||||
fi
|
||||
|
||||
if curl -sSLf "${_PATCH_BASE}/generate.py" -o "${_SITE_PKGS}/mlx_vlm/generate.py"; then
|
||||
substep "patched generate.py (mask trim on cache reuse)"
|
||||
else
|
||||
step "warning" "failed to download generate.py patch — multi-turn image chat may not work" "$C_WARN"
|
||||
fi
|
||||
|
||||
# Clear pycache so patches take effect
|
||||
find "${_SITE_PKGS}/mlx_vlm" -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
substep "cleared bytecode cache"
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────
|
||||
echo ""
|
||||
printf " ${C_TITLE}%s${C_RST}\n" "Qwen3.6 MLX installed!"
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
step "available models" "unsloth/Qwen3.6-35B-A3B-UD-MLX-3bit"
|
||||
substep "unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit"
|
||||
substep "unsloth/Qwen3.6-35B-A3B-MLX-8bit"
|
||||
echo ""
|
||||
step "venv activate" "source ${VENV_DIR}/bin/activate"
|
||||
echo ""
|
||||
step "vision chat" "python -m mlx_vlm.chat --model unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit"
|
||||
substep "Use /image path/to/image.jpg to load an image"
|
||||
echo ""
|
||||
step "gradio UI" "python -m mlx_vlm.chat_ui --model unsloth/Qwen3.6-35B-A3B-UD-MLX-4bit"
|
||||
echo ""
|
||||
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
||||
echo ""
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
"id": "27e68f91"
|
||||
},
|
||||
"outputs": [],
|
||||
"source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh"
|
||||
"source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
{
|
||||
"_comment": "Per-model-family inference parameter defaults. Sources: (1) Ollama params blobs, (2) Existing Unsloth Studio YAML configs. Patterns ordered longest-match-first.",
|
||||
"families": {
|
||||
"qwen3.6": {
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.8,
|
||||
"top_k": 20,
|
||||
"min_p": 0.0,
|
||||
"repetition_penalty": 1.0,
|
||||
"presence_penalty": 1.5
|
||||
},
|
||||
"qwen3.5": {
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.8,
|
||||
|
|
@ -93,6 +101,14 @@
|
|||
"min_p": 0.0,
|
||||
"repetition_penalty": 1.0
|
||||
},
|
||||
"gemma-4": {
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
"top_k": 64,
|
||||
"min_p": 0.0,
|
||||
"repetition_penalty": 1.0,
|
||||
"presence_penalty": 0.0
|
||||
},
|
||||
"gemma-3n": {
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
|
|
@ -361,12 +377,12 @@
|
|||
}
|
||||
},
|
||||
"patterns": [
|
||||
"qwen3.5",
|
||||
"qwen3.6", "qwen3.5",
|
||||
"qwen3-coder", "qwen3-next", "qwen3-vl", "qwen3",
|
||||
"qwen2.5-coder", "qwen2.5-vl", "qwen2.5-omni", "qwen2.5-math", "qwen2.5",
|
||||
"qwen2-vl", "qwen2",
|
||||
"qwq",
|
||||
"gemma-3n", "gemma-3", "medgemma", "gemma-2",
|
||||
"gemma-4", "gemma-3n", "gemma-3", "medgemma", "gemma-2",
|
||||
"llama-4", "llama-3.3", "llama-3.2", "llama-3.1", "llama-3",
|
||||
"phi-4", "phi-3",
|
||||
"mistral-nemo", "mistral-small", "mistral-large", "magistral", "ministral",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
# Model defaults for unsloth/gemma-4-26B-A4B-it
|
||||
# Also applies to: google/gemma-4-26B-A4B-it, unsloth/gemma-4-26B-A4B-it-GGUF
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 4
|
||||
warmup_steps: 5
|
||||
max_steps: 30
|
||||
save_steps: 30
|
||||
weight_decay: 0.001
|
||||
random_seed: 3407
|
||||
packing: false
|
||||
train_on_completions: true
|
||||
gradient_checkpointing: "unsloth"
|
||||
optim: "adamw_8bit"
|
||||
lr_scheduler_type: "linear"
|
||||
|
||||
lora:
|
||||
lora_r: 8
|
||||
lora_alpha: 8
|
||||
lora_dropout: 0.0
|
||||
target_modules:
|
||||
- "all-linear"
|
||||
use_rslora: false
|
||||
use_loftq: false
|
||||
finetune_vision_layers: true
|
||||
finetune_language_layers: true
|
||||
finetune_attention_modules: true
|
||||
finetune_mlp_modules: true
|
||||
|
||||
logging:
|
||||
enable_wandb: false
|
||||
wandb_project: "llm-finetuning"
|
||||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
min_p: 0.0
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Model defaults for unsloth/gemma-4-26B-A4B (base/pretrained)
|
||||
# Also applies to: google/gemma-4-26B-A4B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 4
|
||||
warmup_steps: 5
|
||||
max_steps: 30
|
||||
save_steps: 30
|
||||
weight_decay: 0.001
|
||||
random_seed: 3407
|
||||
packing: false
|
||||
train_on_completions: true
|
||||
gradient_checkpointing: "unsloth"
|
||||
optim: "adamw_8bit"
|
||||
lr_scheduler_type: "linear"
|
||||
|
||||
lora:
|
||||
lora_r: 8
|
||||
lora_alpha: 8
|
||||
lora_dropout: 0.0
|
||||
target_modules:
|
||||
- "all-linear"
|
||||
use_rslora: false
|
||||
use_loftq: false
|
||||
finetune_vision_layers: true
|
||||
finetune_language_layers: true
|
||||
finetune_attention_modules: true
|
||||
finetune_mlp_modules: true
|
||||
|
||||
logging:
|
||||
enable_wandb: false
|
||||
wandb_project: "llm-finetuning"
|
||||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
min_p: 0.0
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Model defaults for unsloth/gemma-4-31B-it
|
||||
# Also applies to: google/gemma-4-31B-it, unsloth/gemma-4-31B-it-GGUF
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 4
|
||||
warmup_steps: 5
|
||||
max_steps: 30
|
||||
save_steps: 30
|
||||
weight_decay: 0.001
|
||||
random_seed: 3407
|
||||
packing: false
|
||||
train_on_completions: true
|
||||
gradient_checkpointing: "unsloth"
|
||||
optim: "adamw_8bit"
|
||||
lr_scheduler_type: "linear"
|
||||
|
||||
lora:
|
||||
lora_r: 8
|
||||
lora_alpha: 8
|
||||
lora_dropout: 0.0
|
||||
target_modules:
|
||||
- "all-linear"
|
||||
use_rslora: false
|
||||
use_loftq: false
|
||||
finetune_vision_layers: true
|
||||
finetune_language_layers: true
|
||||
finetune_attention_modules: true
|
||||
finetune_mlp_modules: true
|
||||
|
||||
logging:
|
||||
enable_wandb: false
|
||||
wandb_project: "llm-finetuning"
|
||||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
min_p: 0.0
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Model defaults for unsloth/gemma-4-31B (base/pretrained)
|
||||
# Also applies to: google/gemma-4-31B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 4
|
||||
warmup_steps: 5
|
||||
max_steps: 30
|
||||
save_steps: 30
|
||||
weight_decay: 0.001
|
||||
random_seed: 3407
|
||||
packing: false
|
||||
train_on_completions: true
|
||||
gradient_checkpointing: "unsloth"
|
||||
optim: "adamw_8bit"
|
||||
lr_scheduler_type: "linear"
|
||||
|
||||
lora:
|
||||
lora_r: 8
|
||||
lora_alpha: 8
|
||||
lora_dropout: 0.0
|
||||
target_modules:
|
||||
- "all-linear"
|
||||
use_rslora: false
|
||||
use_loftq: false
|
||||
finetune_vision_layers: true
|
||||
finetune_language_layers: true
|
||||
finetune_attention_modules: true
|
||||
finetune_mlp_modules: true
|
||||
|
||||
logging:
|
||||
enable_wandb: false
|
||||
wandb_project: "llm-finetuning"
|
||||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
min_p: 0.0
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Model defaults for unsloth/gemma-4-E2B-it
|
||||
# Also applies to: google/gemma-4-E2B-it, unsloth/gemma-4-E2B-it-GGUF
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 4
|
||||
warmup_steps: 5
|
||||
max_steps: 30
|
||||
save_steps: 30
|
||||
weight_decay: 0.001
|
||||
random_seed: 3407
|
||||
packing: false
|
||||
train_on_completions: true
|
||||
gradient_checkpointing: "unsloth"
|
||||
optim: "adamw_8bit"
|
||||
lr_scheduler_type: "linear"
|
||||
|
||||
lora:
|
||||
lora_r: 8
|
||||
lora_alpha: 8
|
||||
lora_dropout: 0.0
|
||||
target_modules:
|
||||
- "all-linear"
|
||||
use_rslora: false
|
||||
use_loftq: false
|
||||
finetune_vision_layers: true
|
||||
finetune_language_layers: true
|
||||
finetune_attention_modules: true
|
||||
finetune_mlp_modules: true
|
||||
|
||||
logging:
|
||||
enable_wandb: false
|
||||
wandb_project: "llm-finetuning"
|
||||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
min_p: 0.0
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Model defaults for unsloth/gemma-4-E2B (base/pretrained)
|
||||
# Also applies to: google/gemma-4-E2B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 4
|
||||
warmup_steps: 5
|
||||
max_steps: 30
|
||||
save_steps: 30
|
||||
weight_decay: 0.001
|
||||
random_seed: 3407
|
||||
packing: false
|
||||
train_on_completions: true
|
||||
gradient_checkpointing: "unsloth"
|
||||
optim: "adamw_8bit"
|
||||
lr_scheduler_type: "linear"
|
||||
|
||||
lora:
|
||||
lora_r: 8
|
||||
lora_alpha: 8
|
||||
lora_dropout: 0.0
|
||||
target_modules:
|
||||
- "all-linear"
|
||||
use_rslora: false
|
||||
use_loftq: false
|
||||
finetune_vision_layers: true
|
||||
finetune_language_layers: true
|
||||
finetune_attention_modules: true
|
||||
finetune_mlp_modules: true
|
||||
|
||||
logging:
|
||||
enable_wandb: false
|
||||
wandb_project: "llm-finetuning"
|
||||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
min_p: 0.0
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Model defaults for unsloth/gemma-4-E4B-it
|
||||
# Also applies to: google/gemma-4-E4B-it, unsloth/gemma-4-E4B-it-GGUF
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 4
|
||||
warmup_steps: 5
|
||||
max_steps: 30
|
||||
save_steps: 30
|
||||
weight_decay: 0.001
|
||||
random_seed: 3407
|
||||
packing: false
|
||||
train_on_completions: true
|
||||
gradient_checkpointing: "unsloth"
|
||||
optim: "adamw_8bit"
|
||||
lr_scheduler_type: "linear"
|
||||
|
||||
lora:
|
||||
lora_r: 8
|
||||
lora_alpha: 8
|
||||
lora_dropout: 0.0
|
||||
target_modules:
|
||||
- "all-linear"
|
||||
use_rslora: false
|
||||
use_loftq: false
|
||||
finetune_vision_layers: true
|
||||
finetune_language_layers: true
|
||||
finetune_attention_modules: true
|
||||
finetune_mlp_modules: true
|
||||
|
||||
logging:
|
||||
enable_wandb: false
|
||||
wandb_project: "llm-finetuning"
|
||||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
min_p: 0.0
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
# Model defaults for unsloth/gemma-4-E4B (base/pretrained)
|
||||
# Also applies to: google/gemma-4-E4B
|
||||
|
||||
training:
|
||||
trust_remote_code: false
|
||||
max_seq_length: 2048
|
||||
num_epochs: 0
|
||||
learning_rate: 2e-4
|
||||
batch_size: 2
|
||||
gradient_accumulation_steps: 4
|
||||
warmup_steps: 5
|
||||
max_steps: 30
|
||||
save_steps: 30
|
||||
weight_decay: 0.001
|
||||
random_seed: 3407
|
||||
packing: false
|
||||
train_on_completions: true
|
||||
gradient_checkpointing: "unsloth"
|
||||
optim: "adamw_8bit"
|
||||
lr_scheduler_type: "linear"
|
||||
|
||||
lora:
|
||||
lora_r: 8
|
||||
lora_alpha: 8
|
||||
lora_dropout: 0.0
|
||||
target_modules:
|
||||
- "all-linear"
|
||||
use_rslora: false
|
||||
use_loftq: false
|
||||
finetune_vision_layers: true
|
||||
finetune_language_layers: true
|
||||
finetune_attention_modules: true
|
||||
finetune_mlp_modules: true
|
||||
|
||||
logging:
|
||||
enable_wandb: false
|
||||
wandb_project: "llm-finetuning"
|
||||
enable_tensorboard: false
|
||||
tensorboard_dir: "runs"
|
||||
log_frequency: 10
|
||||
|
||||
inference:
|
||||
trust_remote_code: false
|
||||
temperature: 1.0
|
||||
top_p: 0.95
|
||||
top_k: 64
|
||||
min_p: 0.0
|
||||
|
|
@ -10,10 +10,12 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||
import jwt
|
||||
|
||||
from .storage import (
|
||||
API_KEY_PREFIX,
|
||||
get_jwt_secret,
|
||||
get_user_and_secret,
|
||||
load_jwt_secret,
|
||||
save_refresh_token,
|
||||
validate_api_key,
|
||||
verify_refresh_token,
|
||||
)
|
||||
|
||||
|
|
@ -50,6 +52,8 @@ def _decode_subject_without_verification(token: str) -> Optional[str]:
|
|||
def create_access_token(
|
||||
subject: str,
|
||||
expires_delta: Optional[timedelta] = None,
|
||||
*,
|
||||
desktop: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Create a signed JWT for the given subject (e.g. username).
|
||||
|
|
@ -57,6 +61,8 @@ def create_access_token(
|
|||
Tokens are valid across restarts because the signing secret is stored in SQLite.
|
||||
"""
|
||||
to_encode = {"sub": subject}
|
||||
if desktop:
|
||||
to_encode["desktop"] = True
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
|
|
@ -68,7 +74,29 @@ def create_access_token(
|
|||
)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str) -> str:
|
||||
def is_desktop_access_token(token: str) -> bool:
|
||||
"""Return true only for a valid desktop-issued JWT access token."""
|
||||
if token.startswith(API_KEY_PREFIX):
|
||||
return False
|
||||
|
||||
subject = _decode_subject_without_verification(token)
|
||||
if subject is None:
|
||||
return False
|
||||
|
||||
record = get_user_and_secret(subject)
|
||||
if record is None:
|
||||
return False
|
||||
|
||||
_salt, _pwd_hash, jwt_secret, _must_change_password = record
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_secret, algorithms = [ALGORITHM])
|
||||
except jwt.InvalidTokenError:
|
||||
return False
|
||||
|
||||
return payload.get("sub") == subject and payload.get("desktop") is True
|
||||
|
||||
|
||||
def create_refresh_token(subject: str, *, desktop: bool = False) -> str:
|
||||
"""
|
||||
Create a random refresh token, store its hash in SQLite, and return it.
|
||||
|
||||
|
|
@ -76,21 +104,28 @@ def create_refresh_token(subject: str) -> str:
|
|||
"""
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
save_refresh_token(token, subject, expires_at.isoformat())
|
||||
save_refresh_token(token, subject, expires_at.isoformat(), is_desktop = desktop)
|
||||
return token
|
||||
|
||||
|
||||
def refresh_access_token(refresh_token: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
def refresh_access_token(
|
||||
refresh_token: str,
|
||||
) -> Tuple[Optional[str], Optional[str], bool]:
|
||||
"""
|
||||
Validate a refresh token and issue a new access token.
|
||||
|
||||
The refresh token itself is NOT consumed — it stays valid until expiry.
|
||||
Returns a new access_token or None if the refresh token is invalid/expired.
|
||||
"""
|
||||
username = verify_refresh_token(refresh_token)
|
||||
if username is None:
|
||||
return None, None
|
||||
return create_access_token(subject = username), username
|
||||
verified = verify_refresh_token(refresh_token)
|
||||
if verified is None:
|
||||
return None, None, False
|
||||
username, is_desktop = verified
|
||||
return (
|
||||
create_access_token(subject = username, desktop = is_desktop),
|
||||
username,
|
||||
is_desktop,
|
||||
)
|
||||
|
||||
|
||||
def reload_secret() -> None:
|
||||
|
|
@ -137,6 +172,18 @@ async def _get_current_subject(
|
|||
...
|
||||
"""
|
||||
token = credentials.credentials
|
||||
|
||||
# --- API key path (sk-unsloth-...) ---
|
||||
if token.startswith(API_KEY_PREFIX):
|
||||
username = validate_api_key(token)
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid or expired API key",
|
||||
)
|
||||
return username
|
||||
|
||||
# --- JWT path ---
|
||||
subject = _decode_subject_without_verification(token)
|
||||
if subject is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -159,7 +206,8 @@ async def _get_current_subject(
|
|||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid token payload",
|
||||
)
|
||||
if must_change_password and not allow_password_change:
|
||||
is_desktop = payload.get("desktop") is True
|
||||
if must_change_password and not allow_password_change and not is_desktop:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_403_FORBIDDEN,
|
||||
detail = "Password change required",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ SQLite storage for authentication data (user credentials + JWT secret).
|
|||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -54,6 +55,10 @@ def generate_bootstrap_password() -> str:
|
|||
# before the user changes the password.
|
||||
ensure_dir(_BOOTSTRAP_PW_PATH.parent)
|
||||
_BOOTSTRAP_PW_PATH.write_text(_bootstrap_password)
|
||||
try:
|
||||
os.chmod(_BOOTSTRAP_PW_PATH, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return _bootstrap_password
|
||||
|
||||
|
|
@ -63,6 +68,17 @@ def get_bootstrap_password() -> Optional[str]:
|
|||
return _bootstrap_password
|
||||
|
||||
|
||||
def _load_bootstrap_password() -> Optional[str]:
|
||||
"""Load an existing bootstrap password without creating one."""
|
||||
global _bootstrap_password
|
||||
_bootstrap_password = None
|
||||
if _BOOTSTRAP_PW_PATH.is_file():
|
||||
bootstrap_password = _BOOTSTRAP_PW_PATH.read_text().strip()
|
||||
if bootstrap_password:
|
||||
_bootstrap_password = bootstrap_password
|
||||
return _bootstrap_password
|
||||
|
||||
|
||||
def clear_bootstrap_password() -> None:
|
||||
"""Delete the persisted bootstrap password file (called after password change)."""
|
||||
global _bootstrap_password
|
||||
|
|
@ -72,7 +88,22 @@ def clear_bootstrap_password() -> None:
|
|||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
"""SHA-256 hash helper used for refresh token storage."""
|
||||
"""SHA-256 hash helper used for refresh token storage.
|
||||
|
||||
Plain SHA-256 is intentional here: refresh tokens are high-entropy
|
||||
random strings from ``secrets.token_urlsafe(48)`` (384 bits of
|
||||
entropy), so a slow KDF (Argon2 / bcrypt / PBKDF2) provides zero
|
||||
additional security — no attacker can brute-force 2^384 regardless
|
||||
of hash speed — while adding tens of ms of CPU to every refresh.
|
||||
See the OWASP Password Storage Cheat Sheet on fast-vs-slow hashing
|
||||
of high-entropy inputs.
|
||||
|
||||
API keys use the separate ``_pbkdf2_api_key`` helper below, which
|
||||
runs PBKDF2-HMAC-SHA256 with a persistent server-side salt — not
|
||||
for cryptographic reasons (128-bit random tokens don't need slow
|
||||
hashing), but because CodeQL's ``py/weak-sensitive-data-hashing``
|
||||
query mislabels API keys as passwords and demands a KDF.
|
||||
"""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
|
|
@ -99,7 +130,39 @@ def get_connection() -> sqlite3.Connection:
|
|||
id INTEGER PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
expires_at TEXT NOT NULL,
|
||||
is_desktop INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
key_prefix TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_internal INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
api_key_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(api_keys)")
|
||||
}
|
||||
if "is_internal" not in api_key_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE api_keys ADD COLUMN is_internal INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app_secrets (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
@ -108,10 +171,107 @@ def get_connection() -> sqlite3.Connection:
|
|||
conn.execute(
|
||||
"ALTER TABLE auth_user ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
refresh_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")
|
||||
}
|
||||
if "is_desktop" not in refresh_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE refresh_tokens ADD COLUMN is_desktop INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
# ── API-key PBKDF2 salt ────────────────────────────────────────────────
|
||||
#
|
||||
# Module-level cache for the persistent API-key PBKDF2 salt. Populated
|
||||
# lazily on first use via ``_get_or_create_api_key_pbkdf2_salt``. Not
|
||||
# protected by a lock because (a) the ``INSERT OR IGNORE`` provides
|
||||
# atomicity at the SQLite layer and (b) concurrent populations converge
|
||||
# on the same value, so the worst case is a harmless duplicate read on
|
||||
# startup.
|
||||
_api_key_pbkdf2_salt_cache: Optional[bytes] = None
|
||||
|
||||
|
||||
def _get_or_create_api_key_pbkdf2_salt() -> bytes:
|
||||
"""Return the persistent API-key PBKDF2 salt, generating it once if missing.
|
||||
|
||||
Stored as a hex-encoded 32-byte random value in the ``app_secrets``
|
||||
table under key ``"api_key_pbkdf2_salt"``. Regenerated only if the row
|
||||
is missing (i.e. fresh install, or operator manually deleted the row
|
||||
and accepts invalidating existing API keys).
|
||||
"""
|
||||
global _api_key_pbkdf2_salt_cache
|
||||
if _api_key_pbkdf2_salt_cache is not None:
|
||||
return _api_key_pbkdf2_salt_cache
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"SELECT value FROM app_secrets WHERE key = ?",
|
||||
("api_key_pbkdf2_salt",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
new_value = secrets.token_hex(32) # 32 bytes -> 64 hex chars
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO app_secrets (key, value) VALUES (?, ?)",
|
||||
("api_key_pbkdf2_salt", new_value),
|
||||
)
|
||||
conn.commit()
|
||||
cur = conn.execute(
|
||||
"SELECT value FROM app_secrets WHERE key = ?",
|
||||
("api_key_pbkdf2_salt",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
salt = bytes.fromhex(row["value"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
_api_key_pbkdf2_salt_cache = salt
|
||||
return salt
|
||||
|
||||
|
||||
_API_KEY_PBKDF2_ITERATIONS = 100_000
|
||||
DESKTOP_SECRET_PREFIX = "desktop-"
|
||||
_DESKTOP_SECRET_HASH_KEY = "desktop_secret_hash"
|
||||
_DESKTOP_SECRET_CREATED_AT_KEY = "desktop_secret_created_at"
|
||||
|
||||
|
||||
def _pbkdf2_api_key(raw_key: str) -> str:
|
||||
"""PBKDF2-HMAC-SHA256 an API key with a persistent server-side salt.
|
||||
|
||||
Used for API-key storage ONLY, not refresh tokens. Matches the
|
||||
PBKDF2 algorithm + iteration count used by the password hasher in
|
||||
``auth/hashing.py`` so the codebase is consistent on which KDF it
|
||||
uses for credential storage.
|
||||
|
||||
Notes on why a slow KDF here is *only* a CodeQL appeasement and
|
||||
*not* a cryptographic requirement: API keys are cryptographically
|
||||
random 128-bit tokens (via ``secrets.token_hex``), so brute force
|
||||
against 2^128 is infeasible regardless of hash speed. CodeQL's
|
||||
``py/weak-sensitive-data-hashing`` query mislabels these tokens as
|
||||
"password" sensitive data and then demands a KDF from its
|
||||
allowlist (Argon2 / scrypt / bcrypt / PBKDF2). Per the query's
|
||||
own recommendation page we use PBKDF2. The persistent salt is
|
||||
still loaded from ``app_secrets`` so an attacker dumping the
|
||||
``api_keys`` table alone cannot derive hashes for candidate
|
||||
tokens without also obtaining the salt row.
|
||||
"""
|
||||
salt = _get_or_create_api_key_pbkdf2_salt()
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
raw_key.encode("utf-8"),
|
||||
salt,
|
||||
_API_KEY_PBKDF2_ITERATIONS,
|
||||
)
|
||||
return dk.hex()
|
||||
|
||||
|
||||
def _pbkdf2_desktop_secret(raw_secret: str) -> str:
|
||||
return _pbkdf2_api_key(raw_secret)
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
"""Check if auth is ready for login (at least one user exists in DB)."""
|
||||
conn = get_connection()
|
||||
|
|
@ -253,6 +413,10 @@ def ensure_default_admin() -> bool:
|
|||
Uses a randomly generated diceware passphrase as the bootstrap password.
|
||||
Returns True when the default admin was created in this call.
|
||||
"""
|
||||
if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is not None:
|
||||
_load_bootstrap_password()
|
||||
return False
|
||||
|
||||
bootstrap_pw = generate_bootstrap_password()
|
||||
try:
|
||||
create_initial_user(
|
||||
|
|
@ -285,12 +449,19 @@ def update_password(username: str, new_password: str) -> bool:
|
|||
conn.commit()
|
||||
if cursor.rowcount > 0:
|
||||
clear_bootstrap_password()
|
||||
clear_desktop_secret()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_refresh_token(token: str, username: str, expires_at: str) -> None:
|
||||
def save_refresh_token(
|
||||
token: str,
|
||||
username: str,
|
||||
expires_at: str,
|
||||
*,
|
||||
is_desktop: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Store a hashed refresh token with its associated username and expiry.
|
||||
"""
|
||||
|
|
@ -299,21 +470,21 @@ def save_refresh_token(token: str, username: str, expires_at: str) -> None:
|
|||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO refresh_tokens (token_hash, username, expires_at)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO refresh_tokens (token_hash, username, expires_at, is_desktop)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(token_hash, username, expires_at),
|
||||
(token_hash, username, expires_at, int(is_desktop)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def verify_refresh_token(token: str) -> Optional[str]:
|
||||
def verify_refresh_token(token: str) -> Optional[Tuple[str, bool]]:
|
||||
"""
|
||||
Verify a refresh token and return the username.
|
||||
Verify a refresh token and return the username plus desktop marker.
|
||||
|
||||
Returns the username if valid and not expired, None otherwise.
|
||||
Returns the username and desktop marker if valid and not expired, None otherwise.
|
||||
The token is NOT consumed — it stays valid until it expires.
|
||||
"""
|
||||
token_hash = _hash_token(token)
|
||||
|
|
@ -328,7 +499,7 @@ def verify_refresh_token(token: str) -> Optional[str]:
|
|||
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT id, username, expires_at FROM refresh_tokens
|
||||
SELECT id, username, expires_at, is_desktop FROM refresh_tokens
|
||||
WHERE token_hash = ?
|
||||
""",
|
||||
(token_hash,),
|
||||
|
|
@ -344,7 +515,7 @@ def verify_refresh_token(token: str) -> Optional[str]:
|
|||
conn.commit()
|
||||
return None
|
||||
|
||||
return row["username"]
|
||||
return row["username"], bool(row["is_desktop"])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -357,3 +528,208 @@ def revoke_user_refresh_tokens(username: str) -> None:
|
|||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_desktop_secret() -> str:
|
||||
"""Create/rotate the local desktop credential and return it once."""
|
||||
ensure_default_admin()
|
||||
raw_secret = DESKTOP_SECRET_PREFIX + secrets.token_urlsafe(48)
|
||||
secret_hash = _pbkdf2_desktop_secret(raw_secret)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)",
|
||||
(_DESKTOP_SECRET_HASH_KEY, secret_hash),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?, ?)",
|
||||
(_DESKTOP_SECRET_CREATED_AT_KEY, now),
|
||||
)
|
||||
conn.commit()
|
||||
return raw_secret
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def validate_desktop_secret(raw_secret: str) -> Optional[str]:
|
||||
"""Return the real admin username when the desktop secret matches."""
|
||||
if not raw_secret.startswith(DESKTOP_SECRET_PREFIX):
|
||||
return None
|
||||
if get_user_and_secret(DEFAULT_ADMIN_USERNAME) is None:
|
||||
return None
|
||||
|
||||
secret_hash = _pbkdf2_desktop_secret(raw_secret)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"SELECT value FROM app_secrets WHERE key = ?",
|
||||
(_DESKTOP_SECRET_HASH_KEY,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if not secrets.compare_digest(row["value"], secret_hash):
|
||||
return None
|
||||
return DEFAULT_ADMIN_USERNAME
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def clear_desktop_secret() -> None:
|
||||
"""Remove backend-side desktop auth state."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"DELETE FROM app_secrets WHERE key IN (?, ?)",
|
||||
(_DESKTOP_SECRET_HASH_KEY, _DESKTOP_SECRET_CREATED_AT_KEY),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API key management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
API_KEY_PREFIX = "sk-unsloth-"
|
||||
|
||||
|
||||
def create_api_key(
|
||||
username: str,
|
||||
name: str,
|
||||
expires_at: Optional[str] = None,
|
||||
internal: bool = False,
|
||||
) -> Tuple[str, dict]:
|
||||
"""Create a new API key for *username*.
|
||||
|
||||
Returns ``(raw_key, row_dict)`` where *raw_key* is shown to the user
|
||||
exactly once. The database only stores the PBKDF2 hash.
|
||||
|
||||
Pass ``internal=True`` for keys minted by workflows (e.g. data-recipe
|
||||
runs) that should not appear in user-facing key listings.
|
||||
"""
|
||||
raw_key = API_KEY_PREFIX + secrets.token_hex(16)
|
||||
key_hash = _pbkdf2_api_key(raw_key)
|
||||
key_prefix = raw_key[len(API_KEY_PREFIX) : len(API_KEY_PREFIX) + 8]
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at, expires_at, is_internal)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
username,
|
||||
key_prefix,
|
||||
key_hash,
|
||||
name,
|
||||
now,
|
||||
expires_at,
|
||||
1 if internal else 0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
cur = conn.execute("SELECT * FROM api_keys WHERE key_hash = ?", (key_hash,))
|
||||
row = cur.fetchone()
|
||||
return raw_key, dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_api_keys(username: str, include_internal: bool = False) -> list:
|
||||
"""Return API keys for *username*. Internal workflow keys are hidden
|
||||
by default so they do not clutter user-facing UIs."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
if include_internal:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT id, username, key_prefix, name, created_at, last_used_at,
|
||||
expires_at, is_active, is_internal
|
||||
FROM api_keys
|
||||
WHERE username = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(username,),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
SELECT id, username, key_prefix, name, created_at, last_used_at,
|
||||
expires_at, is_active, is_internal
|
||||
FROM api_keys
|
||||
WHERE username = ? AND is_internal = 0
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(username,),
|
||||
)
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def revoke_api_key(username: str, key_id: int) -> bool:
|
||||
"""Soft-delete an API key. Returns True if a matching row was found."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"UPDATE api_keys SET is_active = 0 WHERE id = ? AND username = ?",
|
||||
(key_id, username),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def revoke_internal_api_key(key_id: int) -> bool:
|
||||
"""Revoke an internal workflow-minted key without requiring a username.
|
||||
|
||||
Used by the recipe runner to retire its sk-unsloth-* key once the job
|
||||
terminates, shrinking the window a leaked key could be abused.
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"UPDATE api_keys SET is_active = 0 WHERE id = ? AND is_internal = 1",
|
||||
(key_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def validate_api_key(raw_key: str) -> Optional[str]:
|
||||
"""Validate *raw_key* and return the owning username, or ``None``.
|
||||
|
||||
Also updates ``last_used_at`` on success.
|
||||
"""
|
||||
key_hash = _pbkdf2_api_key(raw_key)
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"SELECT id, username, is_active, expires_at FROM api_keys WHERE key_hash = ?",
|
||||
(key_hash,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if not row["is_active"]:
|
||||
return None
|
||||
if row["expires_at"] is not None:
|
||||
expires = datetime.fromisoformat(row["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires:
|
||||
return None
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
|
||||
(datetime.now(timezone.utc).isoformat(), row["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return row["username"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
|
|||
|
|
@ -66,7 +66,10 @@ def show_link(port: int = 8888):
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="white"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
Open Unsloth Studio
|
||||
</a>
|
||||
<p style="color: #333333; margin: 16px 0 0 0; font-size: 13px; font-family: monospace;">
|
||||
<p style="color: #333333; margin: 12px 0 0 0; font-size: 14px; font-weight: bold;">
|
||||
If the link doesn't work, you can scroll down to view the UI generated directly in Colab.
|
||||
</p>
|
||||
<p style="color: #333333; margin: 16px 0 0 0; font-size: 13px; font-family: monospace; font-weight: bold;">
|
||||
{short_url}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ __all__ = [
|
|||
# Config
|
||||
"ModelConfig",
|
||||
"is_vision_model",
|
||||
"scan_trained_models",
|
||||
"scan_trained_loras",
|
||||
"load_model_defaults",
|
||||
"get_base_model_from_lora",
|
||||
|
|
@ -72,6 +73,7 @@ def __getattr__(name):
|
|||
if name in (
|
||||
"is_vision_model",
|
||||
"ModelConfig",
|
||||
"scan_trained_models",
|
||||
"scan_trained_loras",
|
||||
"load_model_defaults",
|
||||
"get_base_model_from_lora",
|
||||
|
|
@ -79,14 +81,15 @@ def __getattr__(name):
|
|||
from utils.models import (
|
||||
is_vision_model,
|
||||
ModelConfig,
|
||||
scan_trained_loras,
|
||||
scan_trained_models,
|
||||
load_model_defaults,
|
||||
get_base_model_from_lora,
|
||||
)
|
||||
|
||||
globals()["is_vision_model"] = is_vision_model
|
||||
globals()["ModelConfig"] = ModelConfig
|
||||
globals()["scan_trained_loras"] = scan_trained_loras
|
||||
globals()["scan_trained_models"] = scan_trained_models
|
||||
globals()["scan_trained_loras"] = scan_trained_models
|
||||
globals()["load_model_defaults"] = load_model_defaults
|
||||
globals()["get_base_model_from_lora"] = get_base_model_from_lora
|
||||
return globals()[name]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ STAGE_PREVIEW = "preview"
|
|||
STAGE_DAG = "dag"
|
||||
STAGE_HEALTHCHECK = "healthcheck"
|
||||
STAGE_SAMPLING = "sampling"
|
||||
STAGE_SOURCE = "source"
|
||||
STAGE_COLUMN_CONFIG = "column_config"
|
||||
STAGE_GENERATING = "generating"
|
||||
STAGE_BATCH = "batch"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,60 @@ from .worker import run_job_process
|
|||
_CTX = mp.get_context("spawn")
|
||||
|
||||
|
||||
def _github_source_estimated_total(recipe: dict) -> int | None:
|
||||
seed_config = recipe.get("seed_config")
|
||||
if not isinstance(seed_config, dict):
|
||||
return None
|
||||
source = seed_config.get("source")
|
||||
if not isinstance(source, dict) or source.get("seed_type") != "github_repo":
|
||||
return None
|
||||
|
||||
repos_raw = source.get("repos")
|
||||
repos = (
|
||||
[repo for repo in repos_raw if isinstance(repo, str) and repo.strip()]
|
||||
if isinstance(repos_raw, list)
|
||||
else []
|
||||
)
|
||||
item_types_raw = source.get("item_types")
|
||||
item_types = (
|
||||
[
|
||||
item
|
||||
for item in item_types_raw
|
||||
if isinstance(item, str) and item in {"issues", "pulls", "commits"}
|
||||
]
|
||||
if isinstance(item_types_raw, list)
|
||||
else []
|
||||
)
|
||||
try:
|
||||
limit = int(source.get("limit") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not repos or not item_types or limit <= 0:
|
||||
return None
|
||||
return len(repos) * len(item_types) * limit
|
||||
|
||||
|
||||
def _source_progress_status(job: Job) -> dict[str, Any] | None:
|
||||
progress = job.source_progress
|
||||
if progress is None:
|
||||
return None
|
||||
return {
|
||||
"source": progress.source,
|
||||
"status": progress.status,
|
||||
"repo": progress.repo,
|
||||
"resource": progress.resource,
|
||||
"page": progress.page,
|
||||
"page_items": progress.page_items,
|
||||
"fetched_items": progress.fetched_items,
|
||||
"estimated_total": progress.estimated_total,
|
||||
"percent": progress.percent,
|
||||
"rate_remaining": progress.rate_remaining,
|
||||
"retry_after_sec": progress.retry_after_sec,
|
||||
"message": progress.message,
|
||||
"updated_at": progress.updated_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Subscription:
|
||||
replay: list[dict]
|
||||
|
|
@ -71,8 +125,20 @@ class JobManager:
|
|||
self._pump_thread: threading.Thread | None = None
|
||||
self._seq: int = 0
|
||||
|
||||
def start(self, *, recipe: dict, run: dict) -> str:
|
||||
"""Spawn the job subprocess (one at a time, no cap)."""
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
recipe: dict,
|
||||
run: dict,
|
||||
internal_api_key_id: int | None = None,
|
||||
) -> str:
|
||||
"""Spawn the job subprocess (one at a time, no cap).
|
||||
|
||||
``internal_api_key_id`` is the row id of a workflow-scoped
|
||||
sk-unsloth-* key minted by the route layer for local providers.
|
||||
JobManager revokes it when the job reaches a terminal state so the
|
||||
key's live window is no longer than the run.
|
||||
"""
|
||||
llm_columns = recipe.get("columns") or []
|
||||
llm_column_count = 0
|
||||
if isinstance(llm_columns, list):
|
||||
|
|
@ -92,18 +158,29 @@ class JobManager:
|
|||
job_id = uuid.uuid4().hex
|
||||
self._job = Job(job_id = job_id, status = "pending", started_at = time.time())
|
||||
self._job.progress_columns_total = llm_column_count
|
||||
self._job.source_progress_estimated_total = _github_source_estimated_total(
|
||||
recipe
|
||||
)
|
||||
self._job.internal_api_key_id = internal_api_key_id
|
||||
self._events.clear()
|
||||
self._seq = 0
|
||||
|
||||
run_payload = dict(run)
|
||||
run_payload["_job_id"] = job_id
|
||||
mp_q = _CTX.Queue()
|
||||
proc = _CTX.Process(
|
||||
target = run_job_process,
|
||||
kwargs = {"event_queue": mp_q, "recipe": recipe, "run": run_payload},
|
||||
daemon = True,
|
||||
from utils.native_path_leases import (
|
||||
native_path_secret_removed_for_child_start,
|
||||
run_without_native_path_secret,
|
||||
)
|
||||
proc.start()
|
||||
|
||||
with native_path_secret_removed_for_child_start():
|
||||
mp_q = _CTX.Queue()
|
||||
proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_job_process,),
|
||||
kwargs = {"event_queue": mp_q, "recipe": recipe, "run": run_payload},
|
||||
daemon = True,
|
||||
)
|
||||
proc.start()
|
||||
|
||||
self._mp_q = mp_q
|
||||
self._proc = proc
|
||||
|
|
@ -163,6 +240,7 @@ class JobManager:
|
|||
"ok": job.column_progress.ok,
|
||||
"failed": job.column_progress.failed,
|
||||
},
|
||||
"source_progress": _source_progress_status(job),
|
||||
"model_usage": {
|
||||
name: {
|
||||
"model": usage.model,
|
||||
|
|
@ -405,6 +483,7 @@ class JobManager:
|
|||
for e in self._drain_queue(mp_q):
|
||||
self._handle_event(job, e)
|
||||
|
||||
retired_job: Job | None = None
|
||||
with self._lock:
|
||||
if self._job and self._job.status in {
|
||||
"pending",
|
||||
|
|
@ -429,6 +508,9 @@ class JobManager:
|
|||
"job_id": self._job.job_id,
|
||||
}
|
||||
)
|
||||
retired_job = self._job
|
||||
if retired_job is not None:
|
||||
self._retire_workflow_key(retired_job)
|
||||
return
|
||||
|
||||
def _handle_event(self, job: Job, event: dict) -> None:
|
||||
|
|
@ -436,6 +518,7 @@ class JobManager:
|
|||
et = event.get("type")
|
||||
msg = event.get("message") if et == "log" else None
|
||||
|
||||
terminal = False
|
||||
with self._lock:
|
||||
if self._job is None or self._job.job_id != job.job_id:
|
||||
return
|
||||
|
|
@ -452,18 +535,43 @@ class JobManager:
|
|||
if self._job.progress.total and self._job.progress.total > 0:
|
||||
self._job.progress.done = self._job.progress.total
|
||||
self._job.progress.percent = 100.0
|
||||
terminal = True
|
||||
if et == EVENT_JOB_ERROR:
|
||||
self._job.status = "error"
|
||||
self._job.finished_at = time.time()
|
||||
self._job.error = event.get("error") or "error"
|
||||
terminal = True
|
||||
if et == EVENT_JOB_CANCELLED:
|
||||
terminal = True
|
||||
|
||||
if msg:
|
||||
upd = parse_log_message(msg)
|
||||
if upd:
|
||||
apply_update(self._job, upd)
|
||||
|
||||
if terminal:
|
||||
self._retire_workflow_key(job)
|
||||
|
||||
self._emit(event)
|
||||
|
||||
def _retire_workflow_key(self, job: Job) -> None:
|
||||
"""Revoke the workflow-scoped sk-unsloth-* key, if one was minted.
|
||||
|
||||
Best-effort: revocation failures are swallowed. The key would
|
||||
expire on its own after 24h, so a missed revoke is a latency
|
||||
concern, not a correctness one.
|
||||
"""
|
||||
key_id = getattr(job, "internal_api_key_id", None)
|
||||
if not key_id:
|
||||
return
|
||||
try:
|
||||
from auth import storage # deferred: avoids circular import
|
||||
|
||||
storage.revoke_internal_api_key(int(key_id))
|
||||
except Exception:
|
||||
pass
|
||||
job.internal_api_key_id = None
|
||||
|
||||
|
||||
_JOB_MANAGER: JobManager | None = None
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
|
@ -17,9 +18,10 @@ from .constants import (
|
|||
STAGE_PREVIEW,
|
||||
STAGE_PROFILING,
|
||||
STAGE_SAMPLING,
|
||||
STAGE_SOURCE,
|
||||
USAGE_RESET_STAGES,
|
||||
)
|
||||
from .types import Job, ModelUsage, Progress
|
||||
from .types import Job, ModelUsage, Progress, SourceProgress
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
|
|
@ -41,6 +43,7 @@ class ParsedUpdate:
|
|||
usage_requests_total: int | None = None
|
||||
usage_rpm: float | None = None
|
||||
usage_section_start: bool | None = None
|
||||
source_progress: SourceProgress | None = None
|
||||
|
||||
|
||||
# kinda of a bummber but currently only option, Best effort parser from data-designer logs -> structured status for UI.
|
||||
|
|
@ -61,9 +64,165 @@ _RE_USAGE_TOKENS = re.compile(
|
|||
_RE_USAGE_REQUESTS = re.compile(
|
||||
r"requests:\s*success=(?P<success>\d+),\s*failed=(?P<failed>\d+),\s*total=(?P<total>\d+),\s*rpm=(?P<rpm>[0-9.]+)"
|
||||
)
|
||||
_RE_GITHUB_PAGE = re.compile(
|
||||
r"^\[(?P<repo>[^\]\s]+/[^\]\s]+)\]\s+"
|
||||
r"(?P<resource>issues|PRs|commits)\s+page\s+(?P<page>\d+)\s+"
|
||||
r"\(\+(?P<items>\d+)\).*?\bremaining=(?P<remaining>\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_GITHUB_RATE_LIMIT = re.compile(
|
||||
r"Rate limit hit\. Sleeping (?P<seconds>\d+)s until reset\.",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_GITHUB_SECONDARY_RATE_LIMIT = re.compile(
|
||||
r"Secondary rate limit(?: on REST)?\. Sleep (?P<seconds>\d+)s\.",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_GITHUB_REST_RATE_LIMIT = re.compile(
|
||||
r"REST 403/429, sleep (?P<seconds>\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_GITHUB_TRANSIENT = re.compile(
|
||||
r"^(?P<api>GraphQL|REST) (?P<code>\d{3}) transient, retrying",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_GITHUB_NETWORK_RETRY = re.compile(
|
||||
r"^(?P<api>GraphQL|REST) network error: .* Retry\.",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_GITHUB_TRIAL_LIMIT = re.compile(
|
||||
r"Trial limit reached for (?P<resource>issues|PRs|commits) \((?P<items>\d+)\)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_GITHUB_COMPLETE = re.compile(
|
||||
r"Scraper complete\. GraphQL calls=\d+ REST calls=\d+",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def parse_log_message(msg: str) -> ParsedUpdate | None:
|
||||
m = _RE_GITHUB_PAGE.search(msg)
|
||||
if m:
|
||||
resource_raw = m.group("resource")
|
||||
resource = "pulls" if resource_raw.lower() == "prs" else resource_raw.lower()
|
||||
repo = m.group("repo")
|
||||
page = int(m.group("page"))
|
||||
page_items = int(m.group("items"))
|
||||
return ParsedUpdate(
|
||||
stage = STAGE_SOURCE,
|
||||
source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = "fetching",
|
||||
repo = repo,
|
||||
resource = resource,
|
||||
page = page,
|
||||
page_items = page_items,
|
||||
rate_remaining = int(m.group("remaining")),
|
||||
message = (
|
||||
f"Scraping GitHub source: {repo} "
|
||||
f"{resource} page {page} (+{page_items})"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
m = _RE_GITHUB_RATE_LIMIT.search(msg)
|
||||
if m:
|
||||
seconds = int(m.group("seconds"))
|
||||
return ParsedUpdate(
|
||||
stage = STAGE_SOURCE,
|
||||
source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = "rate_limited",
|
||||
retry_after_sec = seconds,
|
||||
message = (
|
||||
"Waiting for GitHub rate limit. "
|
||||
"Studio will resume automatically."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
m = _RE_GITHUB_SECONDARY_RATE_LIMIT.search(msg)
|
||||
if m:
|
||||
seconds = int(m.group("seconds"))
|
||||
return ParsedUpdate(
|
||||
stage = STAGE_SOURCE,
|
||||
source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = "rate_limited",
|
||||
retry_after_sec = seconds,
|
||||
message = (
|
||||
"Waiting for GitHub secondary rate limit. "
|
||||
"Studio will resume automatically."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
m = _RE_GITHUB_REST_RATE_LIMIT.search(msg)
|
||||
if m:
|
||||
seconds = int(m.group("seconds"))
|
||||
return ParsedUpdate(
|
||||
stage = STAGE_SOURCE,
|
||||
source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = "rate_limited",
|
||||
retry_after_sec = seconds,
|
||||
message = (
|
||||
"Waiting for GitHub rate limit. "
|
||||
"Studio will resume automatically."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
m = _RE_GITHUB_TRIAL_LIMIT.search(msg)
|
||||
if m:
|
||||
resource_raw = m.group("resource")
|
||||
resource = "pulls" if resource_raw.lower() == "prs" else resource_raw.lower()
|
||||
items = int(m.group("items"))
|
||||
return ParsedUpdate(
|
||||
stage = STAGE_SOURCE,
|
||||
source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = "fetching",
|
||||
resource = resource,
|
||||
message = f"GitHub {resource} trial limit reached ({items}).",
|
||||
),
|
||||
)
|
||||
|
||||
m = _RE_GITHUB_TRANSIENT.search(msg)
|
||||
if m:
|
||||
api = m.group("api")
|
||||
code = m.group("code")
|
||||
return ParsedUpdate(
|
||||
stage = STAGE_SOURCE,
|
||||
source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = "retrying",
|
||||
message = f"GitHub {api} returned {code}; retrying automatically.",
|
||||
),
|
||||
)
|
||||
|
||||
m = _RE_GITHUB_NETWORK_RETRY.search(msg)
|
||||
if m:
|
||||
api = m.group("api")
|
||||
return ParsedUpdate(
|
||||
stage = STAGE_SOURCE,
|
||||
source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = "retrying",
|
||||
message = f"GitHub {api} request failed; retrying automatically.",
|
||||
),
|
||||
)
|
||||
|
||||
if _RE_GITHUB_COMPLETE.search(msg):
|
||||
return ParsedUpdate(
|
||||
stage = STAGE_SOURCE,
|
||||
source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = "completed",
|
||||
message = "GitHub source scrape complete.",
|
||||
),
|
||||
)
|
||||
|
||||
m = _RE_SAMPLERS.search(msg)
|
||||
if m:
|
||||
return ParsedUpdate(
|
||||
|
|
@ -172,6 +331,8 @@ def apply_update(job: Job, update: ParsedUpdate) -> None:
|
|||
job.batch.idx = update.batch_idx
|
||||
if update.batch_total is not None:
|
||||
job.batch.total = update.batch_total
|
||||
if update.source_progress is not None:
|
||||
_apply_source_progress(job, update.source_progress)
|
||||
|
||||
if update.stage in USAGE_RESET_STAGES:
|
||||
# usage summary is a short block so we reset once we move into the next stage.
|
||||
|
|
@ -216,6 +377,67 @@ def apply_update(job: Job, update: ParsedUpdate) -> None:
|
|||
usage.rpm = update.usage_rpm
|
||||
|
||||
|
||||
def _apply_source_progress(job: Job, progress: SourceProgress) -> None:
|
||||
previous = job.source_progress
|
||||
now = time.time()
|
||||
|
||||
page_items = progress.page_items
|
||||
if progress.repo and progress.resource and progress.page is not None:
|
||||
page_key = f"{progress.repo}:{progress.resource}:{progress.page}"
|
||||
count_key = f"{progress.repo}:{progress.resource}"
|
||||
if page_key not in job._source_seen_pages:
|
||||
job._source_seen_pages.add(page_key)
|
||||
job._source_counts[count_key] = int(
|
||||
job._source_counts.get(count_key, 0)
|
||||
) + int(page_items or 0)
|
||||
|
||||
fetched_items = sum(job._source_counts.values())
|
||||
if fetched_items <= 0:
|
||||
fetched_items = progress.fetched_items or (
|
||||
previous.fetched_items if previous else None
|
||||
)
|
||||
|
||||
estimated_total = (
|
||||
progress.estimated_total
|
||||
or job.source_progress_estimated_total
|
||||
or (previous.estimated_total if previous else None)
|
||||
)
|
||||
percent: float | None = progress.percent
|
||||
if percent is None and estimated_total and fetched_items is not None:
|
||||
raw_percent = (float(fetched_items) / float(max(1, estimated_total))) * 100.0
|
||||
percent = 100.0 if progress.status == "completed" else min(99.0, raw_percent)
|
||||
if percent is None and previous is not None:
|
||||
percent = previous.percent
|
||||
|
||||
job.source_progress = SourceProgress(
|
||||
source = "github",
|
||||
status = progress.status or (previous.status if previous else None),
|
||||
repo = progress.repo or (previous.repo if previous else None),
|
||||
resource = progress.resource or (previous.resource if previous else None),
|
||||
page = (
|
||||
progress.page
|
||||
if progress.page is not None
|
||||
else (previous.page if previous else None)
|
||||
),
|
||||
page_items = (
|
||||
page_items
|
||||
if page_items is not None
|
||||
else (previous.page_items if previous else None)
|
||||
),
|
||||
fetched_items = fetched_items,
|
||||
estimated_total = estimated_total,
|
||||
percent = percent,
|
||||
rate_remaining = (
|
||||
progress.rate_remaining
|
||||
if progress.rate_remaining is not None
|
||||
else (previous.rate_remaining if previous else None)
|
||||
),
|
||||
retry_after_sec = progress.retry_after_sec,
|
||||
message = progress.message or (previous.message if previous else None),
|
||||
updated_at = now,
|
||||
)
|
||||
|
||||
|
||||
def _compute_overall_progress(job: Job, column_progress: Progress) -> Progress:
|
||||
if not job.rows:
|
||||
return column_progress
|
||||
|
|
|
|||
|
|
@ -35,6 +35,23 @@ class BatchProgress:
|
|||
total: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceProgress:
|
||||
source: str = "github"
|
||||
status: str | None = None
|
||||
repo: str | None = None
|
||||
resource: str | None = None
|
||||
page: int | None = None
|
||||
page_items: int | None = None
|
||||
fetched_items: int | None = None
|
||||
estimated_total: int | None = None
|
||||
percent: float | None = None
|
||||
rate_remaining: int | None = None
|
||||
retry_after_sec: int | None = None
|
||||
message: str | None = None
|
||||
updated_at: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelUsage:
|
||||
model: str
|
||||
|
|
@ -57,6 +74,7 @@ class Job:
|
|||
progress: Progress = field(default_factory = Progress)
|
||||
column_progress: Progress = field(default_factory = Progress)
|
||||
batch: BatchProgress = field(default_factory = BatchProgress)
|
||||
source_progress: SourceProgress | None = None
|
||||
rows: int | None = None
|
||||
cols: int | None = None
|
||||
error: str | None = None
|
||||
|
|
@ -70,8 +88,15 @@ class Job:
|
|||
processor_artifacts: dict[str, Any] | None = None
|
||||
model_usage: dict[str, ModelUsage] = field(default_factory = dict)
|
||||
progress_columns_total: int | None = None
|
||||
source_progress_estimated_total: int | None = None
|
||||
completed_columns: list[str] = field(default_factory = list)
|
||||
# Id of the internal sk-unsloth-* API key minted for a local-model
|
||||
# workflow. Revoked when the job terminates so the key's live window
|
||||
# matches the run rather than its 24h TTL.
|
||||
internal_api_key_id: int | None = None
|
||||
_current_usage_model: str | None = None
|
||||
_in_usage_summary: bool = False
|
||||
_seen_generation_columns: list[str] = field(default_factory = list)
|
||||
_column_done: dict[str, int] = field(default_factory = dict)
|
||||
_source_counts: dict[str, int] = field(default_factory = dict)
|
||||
_source_seen_pages: set[str] = field(default_factory = set)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ from ..service import build_config_builder, create_data_designer
|
|||
from utils.paths import ensure_dir, recipe_datasets_root
|
||||
|
||||
_ARTIFACT_ROOT = recipe_datasets_root()
|
||||
_RE_GITHUB_CURSOR = re.compile(r"\bcursor=[^\s,]+")
|
||||
_RE_SECRET_TOKEN = re.compile(
|
||||
r"\b(?:(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]+|sk-unsloth-[A-Za-z0-9]+)"
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_log_message(message: str) -> str:
|
||||
message = _RE_GITHUB_CURSOR.sub("cursor=<redacted>", message)
|
||||
return _RE_SECRET_TOKEN.sub("<redacted-token>", message)
|
||||
|
||||
|
||||
class _QueueLogHandler(logging.Handler):
|
||||
|
|
@ -35,7 +44,7 @@ class _QueueLogHandler(logging.Handler):
|
|||
"ts": record.created,
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"message": _sanitize_log_message(record.getMessage()),
|
||||
}
|
||||
self._q.put(event)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
|
|
@ -119,10 +128,16 @@ def run_job_process(
|
|||
# Attach queue logger directly to `data_designer` so parser events survive root resets.
|
||||
handler = _QueueLogHandler(event_queue)
|
||||
handler.setLevel(logging.INFO)
|
||||
data_designer_logger = logging.getLogger("data_designer")
|
||||
data_designer_logger.addHandler(handler)
|
||||
data_designer_logger.setLevel(logging.INFO)
|
||||
data_designer_logger.propagate = True
|
||||
for logger_name in (
|
||||
"data_designer",
|
||||
"scraper",
|
||||
"gh_client",
|
||||
"data_designer_github_repo_seed",
|
||||
):
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = True
|
||||
|
||||
if run_config_raw:
|
||||
designer.set_run_config(RunConfig.model_validate(run_config_raw))
|
||||
|
|
@ -180,8 +195,8 @@ def run_job_process(
|
|||
{
|
||||
"type": EVENT_JOB_ERROR,
|
||||
"ts": time.time(),
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"error": _sanitize_log_message(str(exc)),
|
||||
"stack": _sanitize_log_message(traceback.format_exc(limit = 20)),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,12 @@ _OXC_TOOL_DIR = Path(__file__).resolve().parent / "oxc-validator"
|
|||
_OXC_RUNNER_PATH = _OXC_TOOL_DIR / "validate.mjs"
|
||||
|
||||
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.subprocess_compat import (
|
||||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
class OxcLocalCallableValidatorSpec:
|
||||
name: str
|
||||
|
|
@ -243,7 +249,7 @@ def _run_oxc_batch(
|
|||
}
|
||||
try:
|
||||
tmp_dir = ensure_dir(oxc_validator_tmp_root())
|
||||
env = dict(os.environ)
|
||||
env = child_env_without_native_path_secret()
|
||||
tmp_dir_str = str(tmp_dir)
|
||||
env["TMPDIR"] = tmp_dir_str
|
||||
env["TMP"] = tmp_dir_str
|
||||
|
|
@ -256,6 +262,7 @@ def _run_oxc_batch(
|
|||
capture_output = True,
|
||||
check = False,
|
||||
env = env,
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("OXC subprocess launch failed: %s", exc)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"oxc-parser": "^0.121.0",
|
||||
"oxc-parser": "^0.123.0",
|
||||
"oxlint": "^1.51.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,12 +167,7 @@ def _validate_recipe_runtime_support(
|
|||
recipe: dict[str, Any],
|
||||
model_providers: list[Any],
|
||||
) -> None:
|
||||
if not _recipe_has_llm_columns(recipe):
|
||||
raise ValueError(
|
||||
"Recipe Studio currently requires at least one AI generation step."
|
||||
)
|
||||
|
||||
if not model_providers:
|
||||
if _recipe_has_llm_columns(recipe) and not model_providers:
|
||||
raise ValueError("Add a Provider connection block before running this recipe.")
|
||||
|
||||
|
||||
|
|
@ -266,6 +261,21 @@ def create_data_designer(
|
|||
model_providers = build_model_providers(recipe)
|
||||
_validate_recipe_runtime_support(recipe, model_providers)
|
||||
|
||||
# DataDesigner requires at least one model provider in its registry even
|
||||
# when the pipeline contains no LLM columns. Supply a lightweight stub
|
||||
# so sampler/expression-only recipes can run without a real provider.
|
||||
if not model_providers:
|
||||
from data_designer.config.models import ModelProvider
|
||||
|
||||
model_providers = [
|
||||
ModelProvider(
|
||||
name = "_unused",
|
||||
endpoint = "http://localhost",
|
||||
provider_type = "openai",
|
||||
api_key = None,
|
||||
)
|
||||
]
|
||||
|
||||
return DataDesigner(
|
||||
artifact_path = artifact_path,
|
||||
model_providers = model_providers,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ from core.inference import get_inference_backend
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_LLAMA_CPP_SCRIPTS_WARNING_EMITTED = False
|
||||
|
||||
|
||||
def _is_wsl():
|
||||
"""Detect if running under Windows Subsystem for Linux."""
|
||||
|
|
@ -310,7 +312,7 @@ class ExportBackend:
|
|||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Export merged model (for PEFT models).
|
||||
|
||||
|
|
@ -323,14 +325,21 @@ class ExportBackend:
|
|||
private: Whether to make the repo private
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
Tuple of (success, message, output_path). output_path is the
|
||||
resolved absolute on-disk directory of the saved model when
|
||||
``save_directory`` was set, else None.
|
||||
"""
|
||||
if not self.current_model or not self.current_tokenizer:
|
||||
return False, "No model loaded. Please select a checkpoint first."
|
||||
return False, "No model loaded. Please select a checkpoint first.", None
|
||||
|
||||
if not self.is_peft:
|
||||
return False, "This is not a PEFT model. Use 'Export Base Model' instead."
|
||||
return (
|
||||
False,
|
||||
"This is not a PEFT model. Use 'Export Base Model' instead.",
|
||||
None,
|
||||
)
|
||||
|
||||
output_path: Optional[str] = None
|
||||
try:
|
||||
# Determine save method
|
||||
if format_type == "4-bit (FP4)":
|
||||
|
|
@ -354,6 +363,7 @@ class ExportBackend:
|
|||
# Write export metadata so the Chat page can identify the base model
|
||||
self._write_export_metadata(save_directory)
|
||||
logger.info(f"Model saved successfully to {save_directory}")
|
||||
output_path = str(Path(save_directory).resolve())
|
||||
|
||||
# Push to hub if requested
|
||||
if push_to_hub:
|
||||
|
|
@ -361,6 +371,7 @@ class ExportBackend:
|
|||
return (
|
||||
False,
|
||||
"Repository ID and Hugging Face token required for Hub upload",
|
||||
None,
|
||||
)
|
||||
|
||||
logger.info(f"Pushing merged model to Hub: {repo_id}")
|
||||
|
|
@ -378,14 +389,14 @@ class ExportBackend:
|
|||
)
|
||||
logger.info(f"Model pushed successfully to {repo_id}")
|
||||
|
||||
return True, "Model exported successfully"
|
||||
return True, "Model exported successfully", output_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting merged model: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Export failed: {str(e)}"
|
||||
return False, f"Export failed: {str(e)}", None
|
||||
|
||||
def export_base_model(
|
||||
self,
|
||||
|
|
@ -395,22 +406,26 @@ class ExportBackend:
|
|||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
base_model_id: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Export base model (for non-PEFT models).
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
Tuple of (success, message, output_path). output_path is the
|
||||
resolved absolute on-disk directory of the saved model when
|
||||
``save_directory`` was set, else None.
|
||||
"""
|
||||
if not self.current_model or not self.current_tokenizer:
|
||||
return False, "No model loaded. Please select a checkpoint first."
|
||||
return False, "No model loaded. Please select a checkpoint first.", None
|
||||
|
||||
if self.is_peft:
|
||||
return (
|
||||
False,
|
||||
"This is a PEFT model. Use 'Merged Model' export type instead.",
|
||||
None,
|
||||
)
|
||||
|
||||
output_path: Optional[str] = None
|
||||
try:
|
||||
# Save locally if requested
|
||||
if save_directory:
|
||||
|
|
@ -424,6 +439,7 @@ class ExportBackend:
|
|||
# Write export metadata so the Chat page can identify the base model
|
||||
self._write_export_metadata(save_directory)
|
||||
logger.info(f"Model saved successfully to {save_directory}")
|
||||
output_path = str(Path(save_directory).resolve())
|
||||
|
||||
# Push to hub if requested
|
||||
if push_to_hub:
|
||||
|
|
@ -431,6 +447,7 @@ class ExportBackend:
|
|||
return (
|
||||
False,
|
||||
"Repository ID and Hugging Face token required for Hub upload",
|
||||
None,
|
||||
)
|
||||
|
||||
logger.info(f"Pushing base model to Hub: {repo_id}")
|
||||
|
|
@ -472,16 +489,16 @@ class ExportBackend:
|
|||
)
|
||||
logger.info(f"Model pushed successfully to {repo_id}")
|
||||
else:
|
||||
return False, "Local save directory required for Hub upload"
|
||||
return False, "Local save directory required for Hub upload", None
|
||||
|
||||
return True, "Model exported successfully"
|
||||
return True, "Model exported successfully", output_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting base model: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Export failed: {str(e)}"
|
||||
return False, f"Export failed: {str(e)}", None
|
||||
|
||||
def export_gguf(
|
||||
self,
|
||||
|
|
@ -490,7 +507,7 @@ class ExportBackend:
|
|||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Export model in GGUF format.
|
||||
|
||||
|
|
@ -502,15 +519,43 @@ class ExportBackend:
|
|||
hf_token: Hugging Face token
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
Tuple of (success, message, output_path). output_path is the
|
||||
resolved absolute on-disk directory containing the .gguf
|
||||
files when ``save_directory`` was set, else None.
|
||||
"""
|
||||
if not self.current_model or not self.current_tokenizer:
|
||||
return False, "No model loaded. Please select a checkpoint first."
|
||||
return False, "No model loaded. Please select a checkpoint first.", None
|
||||
|
||||
output_path: Optional[str] = None
|
||||
try:
|
||||
# Convert quantization method to lowercase for unsloth
|
||||
quant_method = quantization_method.lower()
|
||||
|
||||
# Pin convert_hf_to_gguf.py to the same llama.cpp ref as the
|
||||
# llama-quantize binary (Studio installs at a tagged ref via
|
||||
# setup.sh) so it can't drift past the pinned binary's gguf API.
|
||||
# Set before both branches; hub-only export has save_directory == "".
|
||||
global _LLAMA_CPP_SCRIPTS_WARNING_EMITTED
|
||||
try:
|
||||
from unsloth_zoo.llama_cpp import (
|
||||
LLAMA_CPP_DEFAULT_DIR,
|
||||
_resolve_local_convert_script, # noqa: F401
|
||||
)
|
||||
|
||||
os.environ.setdefault(
|
||||
"UNSLOTH_LLAMA_CPP_SCRIPTS_DIR", LLAMA_CPP_DEFAULT_DIR
|
||||
)
|
||||
except ImportError:
|
||||
if not _LLAMA_CPP_SCRIPTS_WARNING_EMITTED:
|
||||
logger.warning(
|
||||
"Unsloth: installed unsloth_zoo does not honor "
|
||||
"UNSLOTH_LLAMA_CPP_SCRIPTS_DIR; convert_hf_to_gguf.py will "
|
||||
"still be downloaded from llama.cpp master and may drift "
|
||||
"past the pinned llama-quantize binary. Upgrade unsloth_zoo "
|
||||
"to activate the local script pin."
|
||||
)
|
||||
_LLAMA_CPP_SCRIPTS_WARNING_EMITTED = True
|
||||
|
||||
# Save locally if requested
|
||||
if save_directory:
|
||||
save_directory = str(resolve_export_dir(save_directory))
|
||||
|
|
@ -601,6 +646,7 @@ class ExportBackend:
|
|||
abs_save_dir,
|
||||
"\n ".join(os.path.basename(f) for f in final_ggufs) or "(none)",
|
||||
)
|
||||
output_path = str(Path(abs_save_dir).resolve())
|
||||
|
||||
# Push to hub if requested
|
||||
if push_to_hub:
|
||||
|
|
@ -608,6 +654,7 @@ class ExportBackend:
|
|||
return (
|
||||
False,
|
||||
"Repository ID and Hugging Face token required for Hub upload",
|
||||
None,
|
||||
)
|
||||
|
||||
logger.info(f"Pushing GGUF model to Hub: {repo_id}")
|
||||
|
|
@ -620,14 +667,18 @@ class ExportBackend:
|
|||
)
|
||||
logger.info(f"GGUF model pushed successfully to {repo_id}")
|
||||
|
||||
return True, f"GGUF model exported successfully ({quantization_method})"
|
||||
return (
|
||||
True,
|
||||
f"GGUF model exported successfully ({quantization_method})",
|
||||
output_path,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting GGUF model: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"GGUF export failed: {str(e)}"
|
||||
return False, f"GGUF export failed: {str(e)}", None
|
||||
|
||||
def export_lora_adapter(
|
||||
self,
|
||||
|
|
@ -636,19 +687,22 @@ class ExportBackend:
|
|||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Export LoRA adapter only (not merged).
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
Tuple of (success, message, output_path). output_path is the
|
||||
resolved absolute on-disk directory of the saved adapter
|
||||
when ``save_directory`` was set, else None.
|
||||
"""
|
||||
if not self.current_model or not self.current_tokenizer:
|
||||
return False, "No model loaded. Please select a checkpoint first."
|
||||
return False, "No model loaded. Please select a checkpoint first.", None
|
||||
|
||||
if not self.is_peft:
|
||||
return False, "This is not a PEFT model. No adapter to export."
|
||||
return False, "This is not a PEFT model. No adapter to export.", None
|
||||
|
||||
output_path: Optional[str] = None
|
||||
try:
|
||||
# Save locally if requested
|
||||
if save_directory:
|
||||
|
|
@ -659,6 +713,7 @@ class ExportBackend:
|
|||
self.current_model.save_pretrained(save_directory)
|
||||
self.current_tokenizer.save_pretrained(save_directory)
|
||||
logger.info(f"Adapter saved successfully to {save_directory}")
|
||||
output_path = str(Path(save_directory).resolve())
|
||||
|
||||
# Push to hub if requested
|
||||
if push_to_hub:
|
||||
|
|
@ -666,6 +721,7 @@ class ExportBackend:
|
|||
return (
|
||||
False,
|
||||
"Repository ID and Hugging Face token required for Hub upload",
|
||||
None,
|
||||
)
|
||||
|
||||
logger.info(f"Pushing LoRA adapter to Hub: {repo_id}")
|
||||
|
|
@ -676,14 +732,14 @@ class ExportBackend:
|
|||
)
|
||||
logger.info(f"Adapter pushed successfully to {repo_id}")
|
||||
|
||||
return True, "LoRA adapter exported successfully"
|
||||
return True, "LoRA adapter exported successfully", output_path
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting LoRA adapter: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Adapter export failed: {str(e)}"
|
||||
return False, f"Adapter export failed: {str(e)}", None
|
||||
|
||||
|
||||
# Global export backend instance
|
||||
|
|
|
|||
|
|
@ -16,19 +16,25 @@ Pattern follows core/inference/orchestrator.py.
|
|||
|
||||
import atexit
|
||||
import structlog
|
||||
from collections import deque
|
||||
from loggers import get_logger
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from typing import Any, Deque, Dict, List, Optional, Tuple
|
||||
from utils.paths import outputs_root
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_CTX = mp.get_context("spawn")
|
||||
|
||||
# Maximum number of captured log lines kept in memory per export
|
||||
# orchestrator. Acts as scrollback for the live export log panel in the
|
||||
# UI. 4000 lines is ~1 MB worst-case at 256 chars/line.
|
||||
_LOG_BUFFER_MAXLEN = 4000
|
||||
|
||||
|
||||
class ExportOrchestrator:
|
||||
"""
|
||||
|
|
@ -44,6 +50,9 @@ class ExportOrchestrator:
|
|||
self._proc: Optional[mp.Process] = None
|
||||
self._cmd_queue: Any = None
|
||||
self._resp_queue: Any = None
|
||||
# Serializes export operations (load_checkpoint, export_*,
|
||||
# cleanup) so concurrent HTTP requests can never interleave
|
||||
# commands on the subprocess queue. Previously unused.
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Local state mirrors (updated from subprocess responses)
|
||||
|
|
@ -51,30 +60,131 @@ class ExportOrchestrator:
|
|||
self.is_vision: bool = False
|
||||
self.is_peft: bool = False
|
||||
|
||||
# ── Live log capture ─────────────────────────────────────
|
||||
# Thread-safe ring buffer of log lines forwarded from the
|
||||
# worker subprocess. Powers the GET /api/export/logs/stream
|
||||
# SSE endpoint that the export dialog consumes.
|
||||
self._log_buffer: Deque[Dict[str, Any]] = deque(maxlen = _LOG_BUFFER_MAXLEN)
|
||||
self._log_lock = threading.Lock()
|
||||
# Monotonically increasing sequence number. Never reset across
|
||||
# operations, so SSE clients can use it as a stable cursor even
|
||||
# if clear_logs() is called mid-session.
|
||||
self._log_seq: int = 0
|
||||
# Snapshot of _log_seq captured at the start of the current run
|
||||
# (updated by clear_logs()). The SSE endpoint defaults its
|
||||
# cursor to this value so a client that connects AFTER the
|
||||
# worker has already emitted its first lines still sees the
|
||||
# full run. Every line appended during the current run has seq
|
||||
# strictly greater than _run_start_seq, and every line from
|
||||
# prior runs has seq less than or equal to it.
|
||||
self._run_start_seq: int = 0
|
||||
# True while an export operation (load/export/cleanup) is
|
||||
# running. The SSE endpoint ends the stream 1 second after
|
||||
# this flips back to False to drain any trailing log lines.
|
||||
self._export_active: bool = False
|
||||
|
||||
atexit.register(self._cleanup)
|
||||
logger.info("ExportOrchestrator initialized (subprocess mode)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Live log capture helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _append_log(self, entry: Dict[str, Any]) -> None:
|
||||
"""Append a log line from the worker subprocess to the buffer.
|
||||
|
||||
Entries look like {"type": "log", "stream": "stdout"|"stderr",
|
||||
"line": "...", "ts": ...}. Each is stamped with a monotonic
|
||||
seq number before it lands in the buffer so SSE clients can
|
||||
cursor through new lines.
|
||||
"""
|
||||
line = entry.get("line")
|
||||
if not line:
|
||||
return
|
||||
with self._log_lock:
|
||||
self._log_seq += 1
|
||||
self._log_buffer.append(
|
||||
{
|
||||
"seq": self._log_seq,
|
||||
"stream": entry.get("stream", "stdout"),
|
||||
"line": line,
|
||||
"ts": entry.get("ts", time.time()),
|
||||
}
|
||||
)
|
||||
|
||||
def clear_logs(self) -> None:
|
||||
"""Drop any buffered log lines from a previous operation.
|
||||
|
||||
Called at the start of each export op so the UI shows only the
|
||||
output of the current run. The seq counter is NOT reset, so an
|
||||
SSE client that captured the cursor before clear_logs() will
|
||||
still see new lines (with strictly greater seq numbers).
|
||||
|
||||
Also snapshots the current seq into ``_run_start_seq`` so the
|
||||
SSE endpoint can anchor its default cursor at the start of
|
||||
this run. Anything appended after this call has seq strictly
|
||||
greater than the snapshot and is reachable via
|
||||
``get_logs_since(get_run_start_seq())``.
|
||||
"""
|
||||
with self._log_lock:
|
||||
self._log_buffer.clear()
|
||||
self._run_start_seq = self._log_seq
|
||||
|
||||
def get_logs_since(self, cursor: int) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""Return log entries with seq > cursor, plus the new cursor."""
|
||||
with self._log_lock:
|
||||
new_entries = [entry for entry in self._log_buffer if entry["seq"] > cursor]
|
||||
if new_entries:
|
||||
return new_entries, new_entries[-1]["seq"]
|
||||
return [], cursor
|
||||
|
||||
def get_current_log_seq(self) -> int:
|
||||
"""Return the current seq counter without reading any entries."""
|
||||
with self._log_lock:
|
||||
return self._log_seq
|
||||
|
||||
def get_run_start_seq(self) -> int:
|
||||
"""Return the seq value captured at the start of the current run.
|
||||
|
||||
The SSE endpoint uses this as the default cursor so a client
|
||||
that connects AFTER the worker has already started emitting
|
||||
output still sees every line from the current run.
|
||||
"""
|
||||
with self._log_lock:
|
||||
return self._run_start_seq
|
||||
|
||||
def is_export_active(self) -> bool:
|
||||
"""True while an export / load / cleanup command is running."""
|
||||
return self._export_active
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subprocess lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _spawn_subprocess(self, config: dict) -> None:
|
||||
"""Spawn a new export subprocess."""
|
||||
from utils.native_path_leases import (
|
||||
native_path_secret_removed_for_child_start,
|
||||
run_without_native_path_secret,
|
||||
)
|
||||
|
||||
from .worker import run_export_process
|
||||
|
||||
self._cmd_queue = _CTX.Queue()
|
||||
self._resp_queue = _CTX.Queue()
|
||||
with native_path_secret_removed_for_child_start():
|
||||
self._cmd_queue = _CTX.Queue()
|
||||
self._resp_queue = _CTX.Queue()
|
||||
|
||||
self._proc = _CTX.Process(
|
||||
target = run_export_process,
|
||||
kwargs = {
|
||||
"cmd_queue": self._cmd_queue,
|
||||
"resp_queue": self._resp_queue,
|
||||
"config": config,
|
||||
},
|
||||
daemon = True,
|
||||
)
|
||||
self._proc.start()
|
||||
self._proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_export_process,),
|
||||
kwargs = {
|
||||
"cmd_queue": self._cmd_queue,
|
||||
"resp_queue": self._resp_queue,
|
||||
"config": config,
|
||||
},
|
||||
daemon = True,
|
||||
)
|
||||
self._proc.start()
|
||||
logger.info("Export subprocess started (pid=%s)", self._proc.pid)
|
||||
|
||||
def _shutdown_subprocess(self, timeout: float = 10.0) -> None:
|
||||
|
|
@ -179,8 +289,26 @@ class ExportOrchestrator:
|
|||
error_msg = resp.get("error", "Unknown error")
|
||||
raise RuntimeError(f"Subprocess error: {error_msg}")
|
||||
|
||||
if rtype == "log":
|
||||
# Forwarded stdout/stderr line from the worker process.
|
||||
self._append_log(resp)
|
||||
continue
|
||||
|
||||
if rtype == "status":
|
||||
logger.info("Export subprocess status: %s", resp.get("message", ""))
|
||||
message = resp.get("message", "")
|
||||
logger.info("Export subprocess status: %s", message)
|
||||
# Surface status messages in the live log panel too so
|
||||
# users see high level progress (e.g. "Importing
|
||||
# Unsloth...", "Loading checkpoint: ...") alongside
|
||||
# subprocess output.
|
||||
if message:
|
||||
self._append_log(
|
||||
{
|
||||
"stream": "status",
|
||||
"line": message,
|
||||
"ts": resp.get("ts", time.time()),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Other response types during wait — skip
|
||||
|
|
@ -231,37 +359,47 @@ class ExportOrchestrator:
|
|||
"hf_token": hf_token,
|
||||
}
|
||||
|
||||
# Always kill existing subprocess and spawn fresh.
|
||||
if self._ensure_subprocess_alive():
|
||||
self._shutdown_subprocess()
|
||||
elif self._proc is not None:
|
||||
self._shutdown_subprocess(timeout = 2)
|
||||
with self._lock:
|
||||
# Start a fresh log buffer for this operation so the UI
|
||||
# sees only the current run's output.
|
||||
self.clear_logs()
|
||||
self._export_active = True
|
||||
try:
|
||||
# Always kill existing subprocess and spawn fresh.
|
||||
if self._ensure_subprocess_alive():
|
||||
self._shutdown_subprocess()
|
||||
elif self._proc is not None:
|
||||
self._shutdown_subprocess(timeout = 2)
|
||||
|
||||
logger.info("Spawning fresh export subprocess for '%s'", checkpoint_path)
|
||||
self._spawn_subprocess(sub_config)
|
||||
logger.info(
|
||||
"Spawning fresh export subprocess for '%s'", checkpoint_path
|
||||
)
|
||||
self._spawn_subprocess(sub_config)
|
||||
|
||||
try:
|
||||
resp = self._wait_response("loaded", timeout = 300)
|
||||
except RuntimeError as exc:
|
||||
self._shutdown_subprocess(timeout = 5)
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
return False, str(exc)
|
||||
try:
|
||||
resp = self._wait_response("loaded")
|
||||
except RuntimeError as exc:
|
||||
self._shutdown_subprocess(timeout = 5)
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
return False, str(exc)
|
||||
|
||||
if resp.get("success"):
|
||||
self.current_checkpoint = resp.get("checkpoint")
|
||||
self.is_vision = resp.get("is_vision", False)
|
||||
self.is_peft = resp.get("is_peft", False)
|
||||
logger.info("Checkpoint '%s' loaded in subprocess", checkpoint_path)
|
||||
return True, resp.get("message", "Loaded successfully")
|
||||
else:
|
||||
error = resp.get("message", "Failed to load checkpoint")
|
||||
logger.error("Failed to load checkpoint: %s", error)
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
return False, error
|
||||
if resp.get("success"):
|
||||
self.current_checkpoint = resp.get("checkpoint")
|
||||
self.is_vision = resp.get("is_vision", False)
|
||||
self.is_peft = resp.get("is_peft", False)
|
||||
logger.info("Checkpoint '%s' loaded in subprocess", checkpoint_path)
|
||||
return True, resp.get("message", "Loaded successfully")
|
||||
else:
|
||||
error = resp.get("message", "Failed to load checkpoint")
|
||||
logger.error("Failed to load checkpoint: %s", error)
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
return False, error
|
||||
finally:
|
||||
self._export_active = False
|
||||
|
||||
def export_merged_model(
|
||||
self,
|
||||
|
|
@ -271,7 +409,7 @@ class ExportOrchestrator:
|
|||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Export merged PEFT model."""
|
||||
return self._run_export(
|
||||
"merged",
|
||||
|
|
@ -293,7 +431,7 @@ class ExportOrchestrator:
|
|||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
base_model_id: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Export base model (non-PEFT)."""
|
||||
return self._run_export(
|
||||
"base",
|
||||
|
|
@ -314,7 +452,7 @@ class ExportOrchestrator:
|
|||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Export model in GGUF format."""
|
||||
return self._run_export(
|
||||
"gguf",
|
||||
|
|
@ -334,7 +472,7 @@ class ExportOrchestrator:
|
|||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Export LoRA adapter only."""
|
||||
return self._run_export(
|
||||
"lora",
|
||||
|
|
@ -347,46 +485,74 @@ class ExportOrchestrator:
|
|||
},
|
||||
)
|
||||
|
||||
def _run_export(self, export_type: str, params: dict) -> Tuple[bool, str]:
|
||||
"""Send an export command to the subprocess and wait for result."""
|
||||
if not self._ensure_subprocess_alive():
|
||||
return False, "No export subprocess running. Load a checkpoint first."
|
||||
def _run_export(
|
||||
self, export_type: str, params: dict
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""Send an export command to the subprocess and wait for result.
|
||||
|
||||
cmd = {"type": "export", "export_type": export_type, **params}
|
||||
Returns ``(success, message, output_path)``. ``output_path`` is the
|
||||
resolved on-disk directory the worker actually wrote to (None when
|
||||
the export only pushed to Hub or failed before any file was
|
||||
written). Surfaced via the export route's ``details.output_path``
|
||||
so the dialog's success screen can show the user where the model
|
||||
landed.
|
||||
"""
|
||||
with self._lock:
|
||||
if not self._ensure_subprocess_alive():
|
||||
return (
|
||||
False,
|
||||
"No export subprocess running. Load a checkpoint first.",
|
||||
None,
|
||||
)
|
||||
|
||||
try:
|
||||
self._send_cmd(cmd)
|
||||
resp = self._wait_response(
|
||||
f"export_{export_type}_done",
|
||||
timeout = 3600, # GGUF for 30B+ models can take 30+ min
|
||||
)
|
||||
return resp.get("success", False), resp.get("message", "")
|
||||
except RuntimeError as exc:
|
||||
return False, str(exc)
|
||||
self.clear_logs()
|
||||
self._export_active = True
|
||||
try:
|
||||
cmd = {"type": "export", "export_type": export_type, **params}
|
||||
try:
|
||||
self._send_cmd(cmd)
|
||||
resp = self._wait_response(
|
||||
f"export_{export_type}_done",
|
||||
timeout = 3600, # GGUF for 30B+ models can take 30+ min
|
||||
)
|
||||
return (
|
||||
resp.get("success", False),
|
||||
resp.get("message", ""),
|
||||
resp.get("output_path"),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return False, str(exc), None
|
||||
finally:
|
||||
self._export_active = False
|
||||
|
||||
def cleanup_memory(self) -> bool:
|
||||
"""Cleanup export-related models from memory."""
|
||||
if not self._ensure_subprocess_alive():
|
||||
# No subprocess — just clear local state
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
return True
|
||||
with self._lock:
|
||||
if not self._ensure_subprocess_alive():
|
||||
# No subprocess — just clear local state
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
return True
|
||||
|
||||
try:
|
||||
self._send_cmd({"type": "cleanup"})
|
||||
resp = self._wait_response("cleanup_done", timeout = 30)
|
||||
success = resp.get("success", False)
|
||||
except RuntimeError:
|
||||
success = False
|
||||
self._export_active = True
|
||||
try:
|
||||
try:
|
||||
self._send_cmd({"type": "cleanup"})
|
||||
resp = self._wait_response("cleanup_done", timeout = 30)
|
||||
success = resp.get("success", False)
|
||||
except RuntimeError:
|
||||
success = False
|
||||
|
||||
# Shut down subprocess after cleanup — no model loaded
|
||||
self._shutdown_subprocess()
|
||||
# Shut down subprocess after cleanup — no model loaded
|
||||
self._shutdown_subprocess()
|
||||
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
return success
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
return success
|
||||
finally:
|
||||
self._export_active = False
|
||||
|
||||
def scan_checkpoints(
|
||||
self, outputs_dir: str = str(outputs_root())
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ Pattern follows core/inference/worker.py and core/training/worker.py.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
|
@ -29,38 +31,164 @@ from typing import Any
|
|||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _activate_transformers_version(model_name: str) -> None:
|
||||
"""Activate the correct transformers version BEFORE any ML imports.
|
||||
# Gate that controls whether captured stdout/stderr lines are forwarded
|
||||
# to the parent's resp_queue (and from there to the export-dialog SSE
|
||||
# stream). Closed by default so the noisy bootstrap phase -- transformers
|
||||
# venv activation, Unsloth/torch imports, base-model resolution, "Top
|
||||
# GGUF/hub models" lists, vision detection, weight loading bars -- is
|
||||
# suppressed in the UI. _handle_export() opens the gate at the start of
|
||||
# the actual export work and leaves it open; the orchestrator always
|
||||
# spawns a fresh subprocess for the next checkpoint load (see
|
||||
# orchestrator._spawn_subprocess) which resets this state.
|
||||
#
|
||||
# Lines dropped while the gate is closed are still echoed to the saved
|
||||
# original stdout/stderr fds so the server console / log file keeps the
|
||||
# full output for debugging.
|
||||
_log_forward_gate = threading.Event()
|
||||
|
||||
If the model needs transformers 5.x, prepend the pre-installed .venv_t5/
|
||||
directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/).
|
||||
|
||||
def _setup_log_capture(resp_queue: Any) -> None:
|
||||
"""Redirect fds 1 and 2 through pipes so every line printed by this
|
||||
worker process and any child process it spawns is forwarded to the
|
||||
parent process via resp_queue as {"type": "log", ...} messages.
|
||||
|
||||
Must be called BEFORE LogConfig.setup_logging and BEFORE any ML
|
||||
imports, otherwise library handlers may capture the original stderr
|
||||
reference and bypass the pipe.
|
||||
|
||||
Lines are also echoed back to the original stdout/stderr so the
|
||||
server console keeps receiving the full subprocess output, even
|
||||
while ``_log_forward_gate`` is closed.
|
||||
"""
|
||||
|
||||
try:
|
||||
saved_out_fd = os.dup(1)
|
||||
saved_err_fd = os.dup(2)
|
||||
except OSError:
|
||||
# dup failed (exotic platforms) - give up quietly, export still
|
||||
# works, just no live log streaming.
|
||||
return
|
||||
|
||||
try:
|
||||
r_out, w_out = os.pipe()
|
||||
r_err, w_err = os.pipe()
|
||||
except OSError:
|
||||
os.close(saved_out_fd)
|
||||
os.close(saved_err_fd)
|
||||
return
|
||||
|
||||
try:
|
||||
os.dup2(w_out, 1)
|
||||
os.dup2(w_err, 2)
|
||||
except OSError:
|
||||
for fd in (saved_out_fd, saved_err_fd, r_out, w_out, r_err, w_err):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
|
||||
# Close the write ends we just dup2'd (fds 1 and 2 are the real
|
||||
# write ends now).
|
||||
os.close(w_out)
|
||||
os.close(w_err)
|
||||
|
||||
# Replace Python's sys.stdout/sys.stderr with line-buffered writers
|
||||
# bound to the (now-redirected) fds 1 and 2.
|
||||
try:
|
||||
sys.stdout = os.fdopen(1, "w", buffering = 1, encoding = "utf-8", errors = "replace")
|
||||
sys.stderr = os.fdopen(2, "w", buffering = 1, encoding = "utf-8", errors = "replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _reader(read_fd: int, stream_name: str, echo_fd: int) -> None:
|
||||
buf = bytearray()
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(read_fd, 4096)
|
||||
except OSError as exc:
|
||||
if exc.errno == errno.EBADF:
|
||||
break
|
||||
continue
|
||||
if not chunk:
|
||||
break
|
||||
# Echo to the original fd so the server console still sees
|
||||
# the full output.
|
||||
try:
|
||||
os.write(echo_fd, chunk)
|
||||
except OSError:
|
||||
pass
|
||||
buf.extend(chunk)
|
||||
# Split on \n OR \r so tqdm-style progress bars update.
|
||||
while True:
|
||||
nl = -1
|
||||
for i, b in enumerate(buf):
|
||||
if b == 0x0A or b == 0x0D:
|
||||
nl = i
|
||||
break
|
||||
if nl < 0:
|
||||
break
|
||||
line = bytes(buf[:nl]).decode("utf-8", errors = "replace")
|
||||
del buf[: nl + 1]
|
||||
if not line:
|
||||
continue
|
||||
if not _log_forward_gate.is_set():
|
||||
# Gate closed (bootstrap phase) -- already echoed to
|
||||
# the saved console fd above; drop the line so the
|
||||
# export dialog doesn't see import / vendoring noise.
|
||||
continue
|
||||
try:
|
||||
resp_queue.put_nowait(
|
||||
{
|
||||
"type": "log",
|
||||
"stream": stream_name,
|
||||
"line": line,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
# Queue put failed (full, closed, etc.) - drop the
|
||||
# line rather than crash the reader thread.
|
||||
pass
|
||||
if buf and _log_forward_gate.is_set():
|
||||
try:
|
||||
resp_queue.put_nowait(
|
||||
{
|
||||
"type": "log",
|
||||
"stream": stream_name,
|
||||
"line": bytes(buf).decode("utf-8", errors = "replace"),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t_out = threading.Thread(
|
||||
target = _reader,
|
||||
args = (r_out, "stdout", saved_out_fd),
|
||||
daemon = True,
|
||||
name = "export-log-stdout",
|
||||
)
|
||||
t_err = threading.Thread(
|
||||
target = _reader,
|
||||
args = (r_err, "stderr", saved_err_fd),
|
||||
daemon = True,
|
||||
name = "export-log-stderr",
|
||||
)
|
||||
t_out.start()
|
||||
t_err.start()
|
||||
|
||||
|
||||
def _activate_transformers_version(model_name: str) -> None:
|
||||
"""Activate the correct transformers version BEFORE any ML imports."""
|
||||
# Ensure backend is on path for utils imports
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
sys.path.insert(0, backend_path)
|
||||
|
||||
from utils.transformers_version import (
|
||||
needs_transformers_5,
|
||||
_resolve_base_model,
|
||||
_ensure_venv_t5_exists,
|
||||
_VENV_T5_DIR,
|
||||
)
|
||||
from utils.transformers_version import activate_transformers_for_subprocess
|
||||
|
||||
resolved = _resolve_base_model(model_name)
|
||||
if needs_transformers_5(resolved):
|
||||
if not _ensure_venv_t5_exists():
|
||||
raise RuntimeError(
|
||||
f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}"
|
||||
)
|
||||
if _VENV_T5_DIR not in sys.path:
|
||||
sys.path.insert(0, _VENV_T5_DIR)
|
||||
logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR)
|
||||
# Propagate to child subprocesses (e.g. GGUF converter)
|
||||
_pp = os.environ.get("PYTHONPATH", "")
|
||||
os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "")
|
||||
else:
|
||||
logger.info("Using default transformers (4.57.x) for %s", model_name)
|
||||
activate_transformers_for_subprocess(model_name)
|
||||
|
||||
|
||||
def _send_response(resp_queue: Any, response: dict) -> None:
|
||||
|
|
@ -78,6 +206,19 @@ def _handle_load(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
load_in_4bit = cmd.get("load_in_4bit", True)
|
||||
trust_remote_code = cmd.get("trust_remote_code", False)
|
||||
|
||||
# Auto-enable trust_remote_code for NemotronH/Nano models.
|
||||
if not trust_remote_code:
|
||||
_NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano")
|
||||
_cp_lower = checkpoint_path.lower()
|
||||
if any(sub in _cp_lower for sub in _NEMOTRON_TRUST_SUBSTRINGS) and (
|
||||
_cp_lower.startswith("unsloth/") or _cp_lower.startswith("nvidia/")
|
||||
):
|
||||
trust_remote_code = True
|
||||
logger.info(
|
||||
"Auto-enabled trust_remote_code for Nemotron model: %s",
|
||||
checkpoint_path,
|
||||
)
|
||||
|
||||
try:
|
||||
_send_response(
|
||||
resp_queue,
|
||||
|
|
@ -126,9 +267,17 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
export_type = cmd["export_type"] # "merged", "base", "gguf", "lora"
|
||||
response_type = f"export_{export_type}_done"
|
||||
|
||||
# Open the log forwarding gate so the user sees the actual export
|
||||
# progress (Unsloth merge bars, file copies, GGUF conversion, etc.)
|
||||
# in the live log panel. The gate stays open for the rest of this
|
||||
# subprocess's life; the orchestrator spawns a fresh subprocess for
|
||||
# the next checkpoint load, which resets the gate to closed.
|
||||
_log_forward_gate.set()
|
||||
|
||||
output_path: Any = None
|
||||
try:
|
||||
if export_type == "merged":
|
||||
success, message = backend.export_merged_model(
|
||||
success, message, output_path = backend.export_merged_model(
|
||||
save_directory = cmd.get("save_directory", ""),
|
||||
format_type = cmd.get("format_type", "16-bit (FP16)"),
|
||||
push_to_hub = cmd.get("push_to_hub", False),
|
||||
|
|
@ -137,7 +286,7 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
private = cmd.get("private", False),
|
||||
)
|
||||
elif export_type == "base":
|
||||
success, message = backend.export_base_model(
|
||||
success, message, output_path = backend.export_base_model(
|
||||
save_directory = cmd.get("save_directory", ""),
|
||||
push_to_hub = cmd.get("push_to_hub", False),
|
||||
repo_id = cmd.get("repo_id"),
|
||||
|
|
@ -146,7 +295,7 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
base_model_id = cmd.get("base_model_id"),
|
||||
)
|
||||
elif export_type == "gguf":
|
||||
success, message = backend.export_gguf(
|
||||
success, message, output_path = backend.export_gguf(
|
||||
save_directory = cmd.get("save_directory", ""),
|
||||
quantization_method = cmd.get("quantization_method", "Q4_K_M"),
|
||||
push_to_hub = cmd.get("push_to_hub", False),
|
||||
|
|
@ -154,7 +303,7 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
hf_token = cmd.get("hf_token"),
|
||||
)
|
||||
elif export_type == "lora":
|
||||
success, message = backend.export_lora_adapter(
|
||||
success, message, output_path = backend.export_lora_adapter(
|
||||
save_directory = cmd.get("save_directory", ""),
|
||||
push_to_hub = cmd.get("push_to_hub", False),
|
||||
repo_id = cmd.get("repo_id"),
|
||||
|
|
@ -170,6 +319,7 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
"type": response_type,
|
||||
"success": success,
|
||||
"message": message,
|
||||
"output_path": output_path,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
|
@ -181,6 +331,7 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
"type": response_type,
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"output_path": None,
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
|
|
@ -226,10 +377,26 @@ def run_export_process(
|
|||
"""
|
||||
import queue as _queue
|
||||
|
||||
# Install fd-level stdout/stderr capture FIRST so every subsequent
|
||||
# print and every child process inherits the redirected fds. This
|
||||
# is what powers the live export log stream in the UI.
|
||||
_setup_log_capture(resp_queue)
|
||||
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
os.environ["PYTHONWARNINGS"] = (
|
||||
"ignore" # Suppress warnings at C-level before imports
|
||||
)
|
||||
# Force unbuffered output from any child Python process (e.g. the
|
||||
# GGUF converter) so their prints surface in the log stream as they
|
||||
# happen rather than at the end.
|
||||
os.environ["PYTHONUNBUFFERED"] = "1"
|
||||
# tqdm defaults to a 10-second mininterval when stdout is not a tty
|
||||
# (which it isn't here -- we redirected fd 1/2 to a pipe). That makes
|
||||
# multi-step progress bars look frozen in the export log panel. Force
|
||||
# frequent flushes so the user sees movement during merge / GGUF
|
||||
# conversion. Has no effect on single-step bars (e.g. "Copying 1
|
||||
# files") which only emit start/end events regardless.
|
||||
os.environ.setdefault("TQDM_MININTERVAL", "0.5")
|
||||
|
||||
import warnings
|
||||
from loggers.config import LogConfig
|
||||
|
|
|
|||
576
studio/backend/core/inference/anthropic_compat.py
Normal file
576
studio/backend/core/inference/anthropic_compat.py
Normal file
|
|
@ -0,0 +1,576 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""
|
||||
Anthropic Messages API ↔ OpenAI format translation utilities.
|
||||
|
||||
Pure functions and a stateful stream emitter — no FastAPI, no I/O.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
|
||||
def _anthropic_image_block_to_openai_part(block: dict) -> Optional[dict]:
|
||||
"""Translate one Anthropic ``image`` block to an OpenAI ``image_url`` part.
|
||||
|
||||
Accepts both source shapes:
|
||||
- ``{"type": "base64", "media_type": "image/jpeg", "data": "..."}``
|
||||
- ``{"type": "url", "url": "https://..."}``
|
||||
|
||||
Returns ``None`` when the source is malformed so the caller can skip it.
|
||||
"""
|
||||
source = block.get("source") or {}
|
||||
stype = source.get("type")
|
||||
if stype == "base64":
|
||||
data = source.get("data")
|
||||
if not data:
|
||||
return None
|
||||
media_type = source.get("media_type") or "image/jpeg"
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:{media_type};base64,{data}"},
|
||||
}
|
||||
if stype == "url":
|
||||
url = source.get("url")
|
||||
if not url:
|
||||
return None
|
||||
return {"type": "image_url", "image_url": {"url": url}}
|
||||
return None
|
||||
|
||||
|
||||
def anthropic_messages_to_openai(
|
||||
messages: list[dict],
|
||||
system: Optional[Union[str, list]] = None,
|
||||
) -> list[dict]:
|
||||
"""Convert Anthropic messages + system to OpenAI-format message dicts.
|
||||
|
||||
User messages that carry ``image`` blocks are emitted as OpenAI
|
||||
multimodal content arrays (``[{type: "text", ...}, {type: "image_url", ...}]``)
|
||||
so they flow through llama-server's native vision pathway.
|
||||
"""
|
||||
result: list[dict] = []
|
||||
|
||||
# System prompt
|
||||
if system:
|
||||
if isinstance(system, str):
|
||||
result.append({"role": "system", "content": system})
|
||||
elif isinstance(system, list):
|
||||
parts = []
|
||||
for block in system:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block["text"])
|
||||
elif isinstance(block, str):
|
||||
parts.append(block)
|
||||
if parts:
|
||||
result.append({"role": "system", "content": "\n".join(parts)})
|
||||
|
||||
for msg in messages:
|
||||
role = msg["role"] if isinstance(msg, dict) else msg.role
|
||||
content = msg["content"] if isinstance(msg, dict) else msg.content
|
||||
|
||||
if isinstance(content, str):
|
||||
result.append({"role": role, "content": content})
|
||||
continue
|
||||
|
||||
if role == "assistant":
|
||||
# Assistant content carries text + tool_use; images aren't
|
||||
# part of Anthropic's assistant content model.
|
||||
text_parts: list[str] = []
|
||||
tool_calls: list[dict] = []
|
||||
for block in content:
|
||||
b = block if isinstance(block, dict) else block.model_dump()
|
||||
btype = b.get("type", "")
|
||||
if btype == "text":
|
||||
text_parts.append(b["text"])
|
||||
elif btype == "tool_use":
|
||||
tool_calls.append(
|
||||
{
|
||||
"id": b["id"],
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": b["name"],
|
||||
"arguments": json.dumps(b["input"]),
|
||||
},
|
||||
}
|
||||
)
|
||||
msg_dict: dict[str, Any] = {"role": "assistant"}
|
||||
if text_parts:
|
||||
msg_dict["content"] = "\n".join(text_parts)
|
||||
if tool_calls:
|
||||
msg_dict["tool_calls"] = tool_calls
|
||||
result.append(msg_dict)
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
# Build an ordered part list so text/image interleaving is
|
||||
# preserved (e.g. [text, image, text, image]). tool_result
|
||||
# blocks become their own OpenAI "tool" role messages.
|
||||
user_parts: list[dict] = []
|
||||
has_image = False
|
||||
tool_results: list[dict] = []
|
||||
for block in content:
|
||||
b = block if isinstance(block, dict) else block.model_dump()
|
||||
btype = b.get("type", "")
|
||||
if btype == "text":
|
||||
user_parts.append({"type": "text", "text": b["text"]})
|
||||
elif btype == "image":
|
||||
part = _anthropic_image_block_to_openai_part(b)
|
||||
if part is not None:
|
||||
user_parts.append(part)
|
||||
has_image = True
|
||||
elif btype == "tool_result":
|
||||
tc = b.get("content", "")
|
||||
if isinstance(tc, list):
|
||||
tc = " ".join(
|
||||
p["text"]
|
||||
for p in tc
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
)
|
||||
tool_results.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": b["tool_use_id"],
|
||||
"content": str(tc),
|
||||
}
|
||||
)
|
||||
|
||||
if has_image:
|
||||
result.append({"role": "user", "content": user_parts})
|
||||
else:
|
||||
# No images — collapse text parts to a plain string so
|
||||
# existing text-only callers keep their simple shape.
|
||||
text = "\n".join(p["text"] for p in user_parts)
|
||||
if text:
|
||||
result.append({"role": "user", "content": text})
|
||||
for tr in tool_results:
|
||||
result.append(tr)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def anthropic_tools_to_openai(tools: list) -> list[dict]:
|
||||
"""Convert Anthropic tool definitions to OpenAI function-tool format."""
|
||||
result = []
|
||||
for t in tools:
|
||||
td = t if isinstance(t, dict) else t.model_dump()
|
||||
result.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": td["name"],
|
||||
"description": td.get("description", ""),
|
||||
"parameters": td.get("input_schema", {}),
|
||||
},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def anthropic_tool_choice_to_openai(tc: Any) -> Any:
|
||||
"""Translate Anthropic `tool_choice` into OpenAI `tool_choice`.
|
||||
|
||||
Anthropic formats (all dict shapes with a ``type`` discriminator):
|
||||
|
||||
- ``{"type": "auto"}`` → ``"auto"``
|
||||
- ``{"type": "any"}`` → ``"required"``
|
||||
- ``{"type": "none"}`` → ``"none"``
|
||||
- ``{"type": "tool", "name": "get_weather"}``
|
||||
→ ``{"type": "function", "function": {"name": "get_weather"}}``
|
||||
|
||||
Returns ``None`` for ``None`` or any unrecognized shape (caller may
|
||||
then fall back to its own default, typically ``"auto"``).
|
||||
"""
|
||||
if tc is None:
|
||||
return None
|
||||
if not isinstance(tc, dict):
|
||||
return None
|
||||
t = tc.get("type")
|
||||
if t == "auto":
|
||||
return "auto"
|
||||
if t == "any":
|
||||
return "required"
|
||||
if t == "none":
|
||||
return "none"
|
||||
if t == "tool":
|
||||
name = tc.get("name")
|
||||
if not name:
|
||||
return None
|
||||
return {"type": "function", "function": {"name": name}}
|
||||
return None
|
||||
|
||||
|
||||
def build_anthropic_sse_event(event_type: str, data: dict) -> str:
|
||||
"""Format a single Anthropic SSE event."""
|
||||
return f"event: {event_type}\ndata: {json.dumps(data)}\n\n"
|
||||
|
||||
|
||||
class AnthropicStreamEmitter:
|
||||
"""Converts generator events from generate_chat_completion_with_tools()
|
||||
into Anthropic Messages SSE strings."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.block_index: int = 0
|
||||
self._text_block_open: bool = False
|
||||
self._prev_text: str = ""
|
||||
self._usage: dict = {}
|
||||
|
||||
def start(self, message_id: str, model: str) -> list[str]:
|
||||
"""Emit message_start and open the first text content block."""
|
||||
events = []
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": model,
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 0, "output_tokens": 0},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
events.extend(self._open_text_block())
|
||||
return events
|
||||
|
||||
def feed(self, event: dict) -> list[str]:
|
||||
"""Process one generator event, return SSE strings."""
|
||||
etype = event.get("type", "")
|
||||
if etype == "content":
|
||||
return self._handle_content(event)
|
||||
elif etype == "tool_start":
|
||||
return self._handle_tool_start(event)
|
||||
elif etype == "tool_end":
|
||||
return self._handle_tool_end(event)
|
||||
elif etype == "metadata":
|
||||
self._usage = event.get("usage", {})
|
||||
return []
|
||||
# status events — no Anthropic equivalent
|
||||
return []
|
||||
|
||||
def finish(self, stop_reason: str = "end_turn") -> list[str]:
|
||||
"""Close any open block and emit message_delta + message_stop."""
|
||||
events = []
|
||||
if self._text_block_open:
|
||||
events.append(self._close_block())
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
|
||||
"usage": {
|
||||
"output_tokens": self._usage.get("completion_tokens", 0),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"message_stop",
|
||||
{
|
||||
"type": "message_stop",
|
||||
},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
def _handle_content(self, event: dict) -> list[str]:
|
||||
cumulative = event.get("text", "")
|
||||
new_text = cumulative[len(self._prev_text) :]
|
||||
self._prev_text = cumulative
|
||||
if not new_text:
|
||||
return []
|
||||
if not self._text_block_open:
|
||||
events = self._open_text_block()
|
||||
else:
|
||||
events = []
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": self.block_index,
|
||||
"delta": {"type": "text_delta", "text": new_text},
|
||||
},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
def _handle_tool_start(self, event: dict) -> list[str]:
|
||||
events = []
|
||||
# Close current text block if open
|
||||
if self._text_block_open:
|
||||
events.append(self._close_block())
|
||||
# Open a tool_use block
|
||||
self.block_index += 1
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": self.block_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": event.get("tool_call_id", ""),
|
||||
"name": event.get("tool_name", ""),
|
||||
"input": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
# Emit the arguments as input_json_delta
|
||||
args = event.get("arguments", {})
|
||||
if args:
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": self.block_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": json.dumps(args),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
def _handle_tool_end(self, event: dict) -> list[str]:
|
||||
events = []
|
||||
# Close the tool_use block
|
||||
events.append(self._close_block())
|
||||
# Emit custom tool_result event (non-standard, ignored by SDKs)
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"tool_result",
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": event.get("tool_call_id", ""),
|
||||
"content": event.get("result", ""),
|
||||
},
|
||||
)
|
||||
)
|
||||
# Open a new text block for the model's next response
|
||||
self.block_index += 1
|
||||
events.extend(self._open_text_block())
|
||||
# Reset text tracking for the next synthesis turn
|
||||
self._prev_text = ""
|
||||
return events
|
||||
|
||||
def _open_text_block(self) -> list[str]:
|
||||
self._text_block_open = True
|
||||
return [
|
||||
build_anthropic_sse_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": self.block_index,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def _close_block(self) -> str:
|
||||
self._text_block_open = False
|
||||
return build_anthropic_sse_event(
|
||||
"content_block_stop",
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
"index": self.block_index,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class AnthropicPassthroughEmitter:
|
||||
"""Converts llama-server's OpenAI-format streaming chunks into Anthropic SSE.
|
||||
|
||||
Used for the client-side tool-use pass-through path: the client (e.g. Claude
|
||||
Code) sends its own tool definitions in the ``tools`` field and expects to
|
||||
execute them itself. We forward them to llama-server and translate the
|
||||
streaming response back to Anthropic format without executing anything.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.block_index: int = -1
|
||||
self._current_block_type: Optional[str] = None # "text" | "tool_use" | None
|
||||
self._tool_call_states: dict = {} # delta index -> {block_index, id, name}
|
||||
self._usage: dict = {}
|
||||
self._stop_reason: str = "end_turn"
|
||||
|
||||
def start(self, message_id: str, model: str) -> list[str]:
|
||||
return [
|
||||
build_anthropic_sse_event(
|
||||
"message_start",
|
||||
{
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
"id": message_id,
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [],
|
||||
"model": model,
|
||||
"stop_reason": None,
|
||||
"stop_sequence": None,
|
||||
"usage": {"input_tokens": 0, "output_tokens": 0},
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def feed_chunk(self, chunk: dict) -> list[str]:
|
||||
"""Process one OpenAI streaming chat.completion.chunk."""
|
||||
events: list[str] = []
|
||||
|
||||
# usage-only chunks carry token totals
|
||||
usage = chunk.get("usage")
|
||||
if usage:
|
||||
self._usage = usage
|
||||
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
return events
|
||||
|
||||
choice = choices[0]
|
||||
delta = choice.get("delta") or {}
|
||||
finish_reason = choice.get("finish_reason")
|
||||
|
||||
# ── Text content ──
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
if self._current_block_type != "text":
|
||||
if self._current_block_type is not None:
|
||||
events.append(self._close_current_block())
|
||||
events.extend(self._open_text_block())
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": self.block_index,
|
||||
"delta": {"type": "text_delta", "text": content},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# ── Tool calls (streaming deltas) ──
|
||||
tool_calls = delta.get("tool_calls") or []
|
||||
for tc in tool_calls:
|
||||
tc_idx = tc.get("index", 0)
|
||||
fn = tc.get("function") or {}
|
||||
if tc_idx not in self._tool_call_states:
|
||||
# New tool call — close prior block, open tool_use block
|
||||
if self._current_block_type is not None:
|
||||
events.append(self._close_current_block())
|
||||
tc_id = tc.get("id", "")
|
||||
tc_name = fn.get("name", "")
|
||||
self.block_index += 1
|
||||
self._current_block_type = "tool_use"
|
||||
self._tool_call_states[tc_idx] = {
|
||||
"block_index": self.block_index,
|
||||
"id": tc_id,
|
||||
"name": tc_name,
|
||||
}
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": self.block_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": tc_id,
|
||||
"name": tc_name,
|
||||
"input": {},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
args_delta = fn.get("arguments", "")
|
||||
if args_delta:
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"content_block_delta",
|
||||
{
|
||||
"type": "content_block_delta",
|
||||
"index": self._tool_call_states[tc_idx]["block_index"],
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args_delta,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# ── Finish reason ──
|
||||
if finish_reason:
|
||||
if finish_reason == "tool_calls":
|
||||
self._stop_reason = "tool_use"
|
||||
elif finish_reason == "length":
|
||||
self._stop_reason = "max_tokens"
|
||||
else:
|
||||
self._stop_reason = "end_turn"
|
||||
|
||||
return events
|
||||
|
||||
def finish(self) -> list[str]:
|
||||
events: list[str] = []
|
||||
if self._current_block_type is not None:
|
||||
events.append(self._close_current_block())
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"message_delta",
|
||||
{
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
"stop_reason": self._stop_reason,
|
||||
"stop_sequence": None,
|
||||
},
|
||||
"usage": {
|
||||
"output_tokens": self._usage.get("completion_tokens", 0),
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
events.append(
|
||||
build_anthropic_sse_event(
|
||||
"message_stop",
|
||||
{"type": "message_stop"},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
def _open_text_block(self) -> list[str]:
|
||||
self.block_index += 1
|
||||
self._current_block_type = "text"
|
||||
return [
|
||||
build_anthropic_sse_event(
|
||||
"content_block_start",
|
||||
{
|
||||
"type": "content_block_start",
|
||||
"index": self.block_index,
|
||||
"content_block": {"type": "text", "text": ""},
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def _close_current_block(self) -> str:
|
||||
idx = self.block_index
|
||||
self._current_block_type = None
|
||||
return build_anthropic_sse_event(
|
||||
"content_block_stop",
|
||||
{
|
||||
"type": "content_block_stop",
|
||||
"index": idx,
|
||||
},
|
||||
)
|
||||
|
|
@ -8,6 +8,7 @@ Supports: SNAC (Orpheus), CSM (Sesame), BiCodec (Spark), DAC (OuteTTS)
|
|||
|
||||
import io
|
||||
import re
|
||||
import subprocess
|
||||
import wave
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
|
@ -16,6 +17,11 @@ from typing import Optional, Tuple
|
|||
import numpy as np
|
||||
import torch
|
||||
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.subprocess_compat import (
|
||||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
|
@ -81,7 +87,6 @@ class AudioCodecManager:
|
|||
return
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
# Clone SparkAudio/Spark-TTS GitHub repo for the sparktts Python package
|
||||
# (same approach as training — the HF model repos don't contain the package)
|
||||
|
|
@ -101,6 +106,8 @@ class AudioCodecManager:
|
|||
spark_code_dir,
|
||||
],
|
||||
check = True,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
|
||||
if spark_code_dir not in sys.path:
|
||||
|
|
@ -119,7 +126,6 @@ class AudioCodecManager:
|
|||
return
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
# Clone OuteTTS repo (same pattern as Spark-TTS / BiCodec)
|
||||
# The pip package has problematic dependencies; the notebook clones and
|
||||
|
|
@ -139,6 +145,8 @@ class AudioCodecManager:
|
|||
outetts_code_dir,
|
||||
],
|
||||
check = True,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
# Remove files that pull in heavy / incompatible dependencies
|
||||
# (matches notebook: gguf_model.py is under models/, others under outetts/)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@
|
|||
import utils.hardware.hardware as hw
|
||||
|
||||
DEFAULT_MODELS_GGUF = [
|
||||
"unsloth/gemma-4-E2B-it-GGUF",
|
||||
"unsloth/gemma-4-E4B-it-GGUF",
|
||||
"unsloth/gemma-4-31B-it-GGUF",
|
||||
"unsloth/gemma-4-26B-A4B-it-GGUF",
|
||||
"unsloth/Qwen3.6-35B-A3B-GGUF",
|
||||
"unsloth/Qwen3.5-4B-GGUF",
|
||||
"unsloth/Qwen3.5-9B-GGUF",
|
||||
"unsloth/Qwen3.5-35B-A3B-GGUF",
|
||||
"unsloth/Qwen3.5-0.8B-GGUF",
|
||||
"unsloth/Llama-3.2-1B-Instruct-GGUF",
|
||||
"unsloth/Llama-3.2-3B-Instruct-GGUF",
|
||||
"unsloth/Llama-3.1-8B-Instruct-GGUF",
|
||||
|
|
@ -15,6 +24,19 @@ DEFAULT_MODELS_GGUF = [
|
|||
]
|
||||
|
||||
DEFAULT_MODELS_STANDARD = [
|
||||
"unsloth/gemma-4-E2B-it-GGUF",
|
||||
"unsloth/gemma-4-E4B-it-GGUF",
|
||||
"unsloth/gemma-4-31B-it-GGUF",
|
||||
"unsloth/gemma-4-26B-A4B-it-GGUF",
|
||||
"unsloth/Qwen3.6-35B-A3B-GGUF",
|
||||
"unsloth/Qwen3.5-4B-GGUF",
|
||||
"unsloth/Qwen3.5-9B-GGUF",
|
||||
"unsloth/Qwen3.5-35B-A3B-GGUF",
|
||||
"unsloth/Qwen3.5-0.8B-GGUF",
|
||||
"unsloth/gemma-4-E2B-it",
|
||||
"unsloth/gemma-4-E4B-it",
|
||||
"unsloth/gemma-4-31B-it",
|
||||
"unsloth/gemma-4-26B-A4B-it",
|
||||
"unsloth/Qwen3-4B-Instruct-2507",
|
||||
"unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit",
|
||||
"unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit",
|
||||
|
|
|
|||
|
|
@ -253,6 +253,10 @@ class InferenceBackend:
|
|||
"""
|
||||
Load any model: base, LoRA adapter, text, or vision.
|
||||
"""
|
||||
# GGUF uses max_seq_length=0 as "model default"; Unsloth crashes on it.
|
||||
if max_seq_length <= 0:
|
||||
max_seq_length = 2048
|
||||
|
||||
try:
|
||||
model_name = config.identifier
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
120
studio/backend/core/inference/llama_server_args.py
Normal file
120
studio/backend/core/inference/llama_server_args.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Validator for user-supplied llama-server pass-through args.
|
||||
|
||||
Studio runs llama-server as a managed subprocess and lets callers pass
|
||||
extra flags directly (CLI: ``unsloth run ... --top-k 20``; HTTP:
|
||||
``LoadRequest.llama_extra_args``). This module is the boundary that
|
||||
rejects only flags Studio fundamentally cannot share with the user --
|
||||
model identity, the auth key, and the network endpoint Studio's HTTP
|
||||
proxy targets. Anything else passes through.
|
||||
|
||||
User-supplied args are appended to ``cmd`` after Studio's auto-set
|
||||
flags, so llama.cpp's last-wins CLI parsing makes the user's value
|
||||
override the auto-set one. That covers tunable knobs the user might
|
||||
reasonably want to override -- ``-c``/``--ctx-size``,
|
||||
``-np``/``--parallel``, ``-fa``/``--flash-attn``,
|
||||
``-ngl``/``--gpu-layers``, ``-t``/``--threads``, ``-fit``/``--fit*``,
|
||||
``--cache-type-k/v``, ``--chat-template-file/-kwargs``,
|
||||
``--spec-*``, ``--jinja``/``--no-jinja``,
|
||||
``--no-context-shift``/``--context-shift``, sampling params, etc.
|
||||
|
||||
Reference: https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable, Optional
|
||||
|
||||
# Each group is the full set of aliases (short + long) for one
|
||||
# hard-denied flag, taken from the llama-server README. If llama.cpp
|
||||
# adds a new alias for an existing denied flag, extend the relevant
|
||||
# group.
|
||||
#
|
||||
# Flags NOT in this list (e.g. -c, --parallel, --flash-attn, -ngl,
|
||||
# -t/--threads, --jinja, --no-context-shift, --fit*, --cache-type-*,
|
||||
# --chat-template-*, --spec-*) pass through and override Studio's
|
||||
# auto-set version via llama.cpp's last-wins CLI parsing.
|
||||
_DENYLIST_GROUPS: tuple[frozenset[str], ...] = (
|
||||
# Model identity -- Studio resolves the model from LoadRequest and
|
||||
# passes -m / mmproj after downloading from HF if needed. A second
|
||||
# -m would point at a different model than the one Studio thinks
|
||||
# is loaded.
|
||||
frozenset({"-m", "--model"}),
|
||||
frozenset({"-mu", "--model-url"}),
|
||||
frozenset({"-dr", "--docker-repo"}),
|
||||
frozenset({"-hf", "-hfr", "--hf-repo"}),
|
||||
frozenset({"-hff", "--hf-file"}),
|
||||
frozenset({"-hfv", "-hfrv", "--hf-repo-v"}),
|
||||
frozenset({"-hffv", "--hf-file-v"}),
|
||||
frozenset({"-hft", "--hf-token"}),
|
||||
frozenset({"-mm", "--mmproj"}),
|
||||
frozenset({"-mmu", "--mmproj-url"}),
|
||||
# Networking -- Studio binds llama-server's port and reverse-proxies
|
||||
# HTTP traffic to it. Retargeting host/port/path/prefix would
|
||||
# orphan Studio's proxy and the UI would lose the server.
|
||||
frozenset({"--host"}),
|
||||
frozenset({"--port"}),
|
||||
frozenset({"--path"}),
|
||||
frozenset({"--api-prefix"}),
|
||||
frozenset({"--reuse-port"}),
|
||||
# Auth / TLS -- Studio terminates auth at its own layer; an
|
||||
# upstream --api-key would shadow Studio's UNSLOTH_DIRECT_STREAM
|
||||
# key, and TLS on llama-server would break the local proxy hop.
|
||||
frozenset({"--api-key"}),
|
||||
frozenset({"--api-key-file"}),
|
||||
frozenset({"--ssl-key-file"}),
|
||||
frozenset({"--ssl-cert-file"}),
|
||||
# Single-model server -- Studio runs one model per llama-server
|
||||
# process and serves its own UI. Enabling multi-model loading or
|
||||
# llama-server's built-in web UI changes the surface clients see.
|
||||
frozenset({"--webui", "--no-webui"}),
|
||||
frozenset({"--models-dir"}),
|
||||
frozenset({"--models-preset"}),
|
||||
frozenset({"--models-max"}),
|
||||
frozenset({"--models-autoload", "--no-models-autoload"}),
|
||||
)
|
||||
|
||||
_DENYLIST: frozenset[str] = frozenset().union(*_DENYLIST_GROUPS)
|
||||
|
||||
|
||||
def _flag_name(token: str) -> Optional[str]:
|
||||
"""Return the flag name for a token, or None if it isn't a flag.
|
||||
|
||||
Peels ``--key=value`` to the bare ``--key``. Plain numeric values
|
||||
like ``-1`` or ``-0.5`` (e.g. ``--seed -1``) are values, not flags;
|
||||
llama-server short-form flags always start with a letter.
|
||||
"""
|
||||
if not token.startswith("-") or token in {"-", "--"}:
|
||||
return None
|
||||
if len(token) >= 2 and (token[1].isdigit() or token[1] == "."):
|
||||
return None
|
||||
return token.split("=", 1)[0]
|
||||
|
||||
|
||||
def validate_extra_args(args: Optional[Iterable[str]]) -> list[str]:
|
||||
"""Validate user-supplied llama-server args.
|
||||
|
||||
Returns the args as a flat list ready to extend the llama-server
|
||||
command. Raises ``ValueError`` (with the offending flag in the
|
||||
message) the moment a token resolves to a Studio-managed flag.
|
||||
"""
|
||||
if not args:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for raw in args:
|
||||
token = str(raw)
|
||||
flag = _flag_name(token)
|
||||
if flag is not None and flag in _DENYLIST:
|
||||
raise ValueError(
|
||||
f"llama-server flag '{flag}' is managed by Unsloth Studio "
|
||||
f"and cannot be passed as an extra arg"
|
||||
)
|
||||
out.append(token)
|
||||
return out
|
||||
|
||||
|
||||
def is_managed_flag(flag: str) -> bool:
|
||||
"""True if ``flag`` is a Studio-managed llama-server flag."""
|
||||
return flag in _DENYLIST
|
||||
|
|
@ -109,12 +109,13 @@ class InferenceOrchestrator:
|
|||
self._top_models_ready.wait(timeout = 5)
|
||||
top_gguf = self._top_gguf_cache or []
|
||||
top_hub = self._top_hub_cache or []
|
||||
# GGUFs first, then hub models, then static fallbacks.
|
||||
# Curated static defaults first (editorial picks like new models),
|
||||
# then HF download-ranked models to backfill.
|
||||
# Send extras so the frontend still has 4 per category
|
||||
# after removing already-downloaded models.
|
||||
result: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for m in top_gguf + top_hub + self._static_models:
|
||||
for m in self._static_models + top_gguf + top_hub:
|
||||
if m not in seen:
|
||||
result.append(m)
|
||||
seen.add(m)
|
||||
|
|
@ -165,23 +166,30 @@ class InferenceOrchestrator:
|
|||
|
||||
def _spawn_subprocess(self, config: dict) -> None:
|
||||
"""Spawn a new inference subprocess."""
|
||||
from utils.native_path_leases import (
|
||||
native_path_secret_removed_for_child_start,
|
||||
run_without_native_path_secret,
|
||||
)
|
||||
|
||||
from .worker import run_inference_process
|
||||
|
||||
self._cmd_queue = _CTX.Queue()
|
||||
self._resp_queue = _CTX.Queue()
|
||||
self._cancel_event = _CTX.Event()
|
||||
with native_path_secret_removed_for_child_start():
|
||||
self._cmd_queue = _CTX.Queue()
|
||||
self._resp_queue = _CTX.Queue()
|
||||
self._cancel_event = _CTX.Event()
|
||||
|
||||
self._proc = _CTX.Process(
|
||||
target = run_inference_process,
|
||||
kwargs = {
|
||||
"cmd_queue": self._cmd_queue,
|
||||
"resp_queue": self._resp_queue,
|
||||
"cancel_event": self._cancel_event,
|
||||
"config": config,
|
||||
},
|
||||
daemon = True,
|
||||
)
|
||||
self._proc.start()
|
||||
self._proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_inference_process,),
|
||||
kwargs = {
|
||||
"cmd_queue": self._cmd_queue,
|
||||
"resp_queue": self._resp_queue,
|
||||
"cancel_event": self._cancel_event,
|
||||
"config": config,
|
||||
},
|
||||
daemon = True,
|
||||
)
|
||||
self._proc.start()
|
||||
logger.info("Inference subprocess started (pid=%s)", self._proc.pid)
|
||||
|
||||
def _cancel_generation(self) -> None:
|
||||
|
|
@ -707,6 +715,17 @@ class InferenceOrchestrator:
|
|||
|
||||
def unload_model(self, model_name: str) -> bool:
|
||||
"""Unload a model from the subprocess."""
|
||||
if model_name in self.loading_models:
|
||||
logger.info(
|
||||
"Cancelling in-flight load for model '%s' by terminating subprocess",
|
||||
model_name,
|
||||
)
|
||||
self._shutdown_subprocess(timeout = 0.5)
|
||||
self.loading_models.discard(model_name)
|
||||
self.active_model_name = None
|
||||
self.models.clear()
|
||||
return True
|
||||
|
||||
if not self._ensure_subprocess_alive():
|
||||
# No subprocess — just clear local state
|
||||
self.models.pop(model_name, None)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import os
|
|||
os.environ["UNSLOTH_IS_PRESENT"] = "1"
|
||||
|
||||
import random
|
||||
import re
|
||||
import shlex
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
|
|
@ -26,11 +28,240 @@ from loggers import get_logger
|
|||
logger = get_logger(__name__)
|
||||
|
||||
_EXEC_TIMEOUT = 300 # 5 minutes
|
||||
|
||||
# Pre-import modules used in _sandbox_preexec at module level so that
|
||||
# the preexec_fn closure does not trigger the import machinery in the
|
||||
# forked child (which can deadlock in multi-threaded servers).
|
||||
_libc = None
|
||||
if sys.platform == "linux":
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
|
||||
_libc_name = ctypes.util.find_library("c")
|
||||
if _libc_name:
|
||||
_libc = ctypes.CDLL(_libc_name, use_errno = True)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
_resource = None
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
import resource as _resource
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Strict raster-image allowlist for sandbox file serving.
|
||||
# No .svg (XSS risk via embedded scripts), no .html, no .pdf.
|
||||
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
|
||||
_MAX_OUTPUT_CHARS = 8000 # truncate long output
|
||||
_BASH_BLOCKED_WORDS = {"rm", "sudo", "dd", "chmod", "mkfs", "shutdown", "reboot"}
|
||||
_BLOCKED_COMMANDS_COMMON = frozenset(
|
||||
{
|
||||
"rm",
|
||||
"sudo",
|
||||
"su",
|
||||
"dd",
|
||||
"chmod",
|
||||
"chown",
|
||||
"mkfs",
|
||||
"shutdown",
|
||||
"reboot",
|
||||
"passwd",
|
||||
"mount",
|
||||
"umount",
|
||||
"fdisk",
|
||||
"kill",
|
||||
"killall",
|
||||
"pkill",
|
||||
}
|
||||
)
|
||||
_BLOCKED_COMMANDS_WIN = frozenset(
|
||||
{
|
||||
"rmdir",
|
||||
"takeown",
|
||||
"icacls",
|
||||
"runas",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
}
|
||||
)
|
||||
_BLOCKED_COMMANDS = (
|
||||
_BLOCKED_COMMANDS_COMMON | _BLOCKED_COMMANDS_WIN
|
||||
if sys.platform == "win32"
|
||||
else _BLOCKED_COMMANDS_COMMON
|
||||
)
|
||||
|
||||
|
||||
def _find_blocked_commands(command: str) -> set[str]:
|
||||
"""Detect blocked commands using shlex tokenization and regex scanning.
|
||||
|
||||
Catches: full paths (/usr/bin/sudo), quoted strings ("sudo"),
|
||||
split-quotes (su""do), backslash escapes (\\rm), and command-position
|
||||
words after ;, |, &&, $().
|
||||
"""
|
||||
blocked = set()
|
||||
|
||||
# 1. shlex tokenization (handles quotes, escapes, concatenation)
|
||||
try:
|
||||
tokens = (
|
||||
shlex.split(command)
|
||||
if sys.platform != "win32"
|
||||
else shlex.split(command, posix = False)
|
||||
)
|
||||
except ValueError:
|
||||
tokens = command.split()
|
||||
|
||||
for token in tokens:
|
||||
base = os.path.basename(token).lower()
|
||||
# Strip common Windows executable extensions so that
|
||||
# runas.exe, shutdown.bat, etc. match the blocklist.
|
||||
stem, ext = os.path.splitext(base)
|
||||
if ext in {".exe", ".com", ".bat", ".cmd"}:
|
||||
base = stem
|
||||
if base in _BLOCKED_COMMANDS:
|
||||
blocked.add(base)
|
||||
|
||||
# 2. Regex: catch blocked words at shell command boundaries
|
||||
# (semicolons, pipes, &&, ||, backticks, $(), <(), subshells, newlines)
|
||||
# Uses a single combined pattern for all blocked words.
|
||||
# Handles optional Unix path prefix (/usr/bin/) and Windows drive
|
||||
# letter prefix (C:\Windows\...\).
|
||||
lowered = command.lower()
|
||||
if _BLOCKED_COMMANDS:
|
||||
words_alt = "|".join(re.escape(w) for w in sorted(_BLOCKED_COMMANDS))
|
||||
pattern = (
|
||||
rf"(?:^|[;&|`\n(]\s*|[$]\(\s*|<\(\s*)"
|
||||
rf"(?:[\w./\\-]*/|[a-zA-Z]:[/\\][\w./\\-]*)?"
|
||||
rf"({words_alt})(?:\.(?:exe|com|bat|cmd))?\b"
|
||||
)
|
||||
blocked.update(re.findall(pattern, lowered))
|
||||
|
||||
# 3. Check for nested shell invocations (bash -c 'sudo whoami',
|
||||
# bash -lc '...', bash --login -c '...', cmd /c '...').
|
||||
# When a -c or /c flag is found, look backwards for a shell name
|
||||
# (skipping intermediate flags like --login, -l, -x) and recursively
|
||||
# scan the nested command string.
|
||||
_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "csh", "tcsh", "fish"}
|
||||
_SHELLS_WIN = {"cmd", "cmd.exe"}
|
||||
for i, token in enumerate(tokens):
|
||||
tok_lower = token.lower()
|
||||
# Match -c exactly, or combined flags ending in c (e.g. -lc, -xc)
|
||||
is_unix_c = tok_lower == "-c" or (
|
||||
tok_lower.startswith("-")
|
||||
and tok_lower.endswith("c")
|
||||
and not tok_lower.startswith("--")
|
||||
)
|
||||
is_win_c = tok_lower == "/c"
|
||||
if not (is_unix_c or is_win_c) or i < 1 or i + 1 >= len(tokens):
|
||||
continue
|
||||
# Look backwards past any flags to find the shell binary.
|
||||
# On Unix, flags start with - (skip those). On Windows, flags
|
||||
# start with / but so do absolute paths, so only skip short
|
||||
# single-char /X flags (not /bin/bash style paths).
|
||||
for j in range(i - 1, -1, -1):
|
||||
prev = tokens[j]
|
||||
if prev.startswith("-"):
|
||||
continue # skip Unix flags like --login, -l
|
||||
if is_win_c and prev.startswith("/") and len(prev) <= 3:
|
||||
continue # skip Windows flags like /s, /q (not /bin/bash)
|
||||
prev_base = os.path.basename(prev).lower()
|
||||
if is_unix_c and prev_base in _SHELLS:
|
||||
blocked |= _find_blocked_commands(tokens[i + 1])
|
||||
elif is_win_c and prev_base in _SHELLS_WIN:
|
||||
blocked |= _find_blocked_commands(tokens[i + 1])
|
||||
break # stop at first non-flag token
|
||||
|
||||
return blocked
|
||||
|
||||
|
||||
def _build_safe_env(workdir: str) -> dict[str, str]:
|
||||
"""Build a minimal, credential-free environment for sandboxed subprocesses.
|
||||
|
||||
Strips HF_TOKEN, WANDB_API_KEY, AWS_*, GH_TOKEN, LD_PRELOAD, DYLD_*, etc.
|
||||
Preserves the active Python interpreter and virtualenv directories in PATH
|
||||
so that pip, uv, and packages installed in the Studio runtime remain
|
||||
accessible.
|
||||
"""
|
||||
# Start with the directory containing the running Python interpreter
|
||||
# so that subprocess calls to 'python', 'pip', etc. resolve to the
|
||||
# same environment the Studio server is running in.
|
||||
exe_dir = os.path.dirname(sys.executable)
|
||||
path_entries = [exe_dir] if exe_dir else []
|
||||
|
||||
# If a virtualenv is active, include its bin/Scripts directory.
|
||||
venv = os.environ.get("VIRTUAL_ENV")
|
||||
if venv:
|
||||
venv_bin = os.path.join(venv, "Scripts" if sys.platform == "win32" else "bin")
|
||||
if venv_bin not in path_entries:
|
||||
path_entries.append(venv_bin)
|
||||
|
||||
if sys.platform == "win32":
|
||||
sysroot = os.environ.get("SystemRoot", r"C:\Windows")
|
||||
path_entries.extend([os.path.join(sysroot, "System32"), sysroot])
|
||||
else:
|
||||
path_entries.extend(["/usr/local/bin", "/usr/bin", "/bin"])
|
||||
|
||||
# Deduplicate while preserving order
|
||||
deduped = list(dict.fromkeys(p for p in path_entries if p))
|
||||
|
||||
env = {
|
||||
"PATH": os.pathsep.join(deduped),
|
||||
"HOME": workdir,
|
||||
"TMPDIR": workdir,
|
||||
"LANG": os.environ.get("LANG", "C.UTF-8"),
|
||||
"TERM": "dumb",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
}
|
||||
if venv:
|
||||
env["VIRTUAL_ENV"] = venv
|
||||
# Windows needs SystemRoot for Python/subprocess to work
|
||||
if sys.platform == "win32":
|
||||
env["SystemRoot"] = os.environ.get("SystemRoot", r"C:\Windows")
|
||||
return env
|
||||
|
||||
|
||||
def _sandbox_preexec():
|
||||
"""Pre-exec hook: drop privilege escalation ability and set resource limits.
|
||||
|
||||
On Linux, applies PR_SET_NO_NEW_PRIVS so sudo/su/pkexec fail at the
|
||||
kernel level. On Linux and macOS, sets RLIMIT_FSIZE.
|
||||
No-op on Windows (use creationflags instead).
|
||||
|
||||
Note: RLIMIT_NPROC is intentionally NOT set because Linux enforces it
|
||||
per real UID, not per process tree, so it would starve the Studio
|
||||
server and other sessions sharing the same user account.
|
||||
|
||||
All modules and handles are resolved at import time (module level) so
|
||||
this function does not trigger Python imports in the forked child,
|
||||
avoiding potential deadlocks in multi-threaded servers.
|
||||
"""
|
||||
if _libc is not None:
|
||||
try:
|
||||
# PR_SET_NO_NEW_PRIVS = 38, arg2 = 1 (enable)
|
||||
_libc.prctl(38, 1, 0, 0, 0)
|
||||
except (OSError, AttributeError):
|
||||
pass # Not available (container, old kernel, etc.)
|
||||
|
||||
if _resource is not None:
|
||||
try:
|
||||
# Limit file size to 100MB (prevents disk filling)
|
||||
_resource.setrlimit(
|
||||
_resource.RLIMIT_FSIZE, (100 * 1024 * 1024, 100 * 1024 * 1024)
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _get_shell_cmd(command: str) -> list[str]:
|
||||
"""Return the platform-appropriate shell invocation for a command string."""
|
||||
if sys.platform == "win32":
|
||||
return ["cmd", "/c", command]
|
||||
return ["bash", "-c", command]
|
||||
|
||||
|
||||
# Per-session working directories so each chat thread gets its own sandbox.
|
||||
# Falls back to a shared ~/studio_sandbox/ for API callers without a session_id.
|
||||
# Falls back to a shared ~/studio_sandbox/_default for API callers without a
|
||||
# session_id.
|
||||
_workdirs: dict[str, str] = {}
|
||||
|
||||
|
||||
|
|
@ -51,7 +282,7 @@ def _get_workdir(session_id: str | None = None) -> str:
|
|||
if not os.path.realpath(workdir).startswith(os.path.realpath(sandbox_root)):
|
||||
workdir = os.path.join(sandbox_root, "_invalid")
|
||||
else:
|
||||
workdir = sandbox_root
|
||||
workdir = os.path.join(sandbox_root, "_default")
|
||||
os.makedirs(workdir, exist_ok = True)
|
||||
_workdirs[key] = workdir
|
||||
return _workdirs[key]
|
||||
|
|
@ -424,6 +655,7 @@ def _check_signal_escape_patterns(code: str):
|
|||
|
||||
signal_tampering = []
|
||||
exception_catching = []
|
||||
shell_escapes = []
|
||||
warnings = []
|
||||
|
||||
def _ast_name_matches(node, names):
|
||||
|
|
@ -441,10 +673,84 @@ def _check_signal_escape_patterns(code: str):
|
|||
return full_name in names
|
||||
return False
|
||||
|
||||
# Dangerous os/subprocess functions that can execute shell commands
|
||||
_SHELL_EXEC_FUNCS = frozenset(
|
||||
{
|
||||
"os.system",
|
||||
"os.popen",
|
||||
"os.popen2",
|
||||
"os.popen3",
|
||||
"os.popen4",
|
||||
"os.execl",
|
||||
"os.execle",
|
||||
"os.execlp",
|
||||
"os.execlpe",
|
||||
"os.execv",
|
||||
"os.execve",
|
||||
"os.execvp",
|
||||
"os.execvpe",
|
||||
"os.spawnl",
|
||||
"os.spawnle",
|
||||
"os.spawnlp",
|
||||
"os.spawnlpe",
|
||||
"os.spawnv",
|
||||
"os.spawnve",
|
||||
"os.spawnvp",
|
||||
"os.spawnvpe",
|
||||
"os.posix_spawn",
|
||||
"os.posix_spawnp",
|
||||
"subprocess.run",
|
||||
"subprocess.call",
|
||||
"subprocess.check_call",
|
||||
"subprocess.check_output",
|
||||
"subprocess.Popen",
|
||||
"subprocess.getoutput",
|
||||
"subprocess.getstatusoutput",
|
||||
}
|
||||
)
|
||||
|
||||
def _extract_string_from_node(node):
|
||||
"""Extract a plain string value from an AST node, if it is a constant."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
return None
|
||||
|
||||
def _extract_strings_from_list(node):
|
||||
"""Extract string elements from an AST List or Tuple node."""
|
||||
if isinstance(node, (ast.List, ast.Tuple)):
|
||||
parts = []
|
||||
for elt in node.elts:
|
||||
s = _extract_string_from_node(elt)
|
||||
if s is not None:
|
||||
parts.append(s)
|
||||
return parts
|
||||
return []
|
||||
|
||||
# Keyword argument names that carry command content (as opposed to
|
||||
# control flags like check=True, text=True, capture_output=True).
|
||||
_CMD_KWARGS = frozenset({"args", "command", "executable", "path", "file"})
|
||||
|
||||
def _check_args_for_blocked(args_nodes):
|
||||
"""Check if any call arguments contain blocked commands."""
|
||||
found = set()
|
||||
for arg in args_nodes:
|
||||
s = _extract_string_from_node(arg)
|
||||
if s is not None:
|
||||
found |= _find_blocked_commands(s)
|
||||
strs = _extract_strings_from_list(arg)
|
||||
for s in strs:
|
||||
found |= _find_blocked_commands(s)
|
||||
return found
|
||||
|
||||
class SignalEscapeVisitor(ast.NodeVisitor):
|
||||
def __init__(self):
|
||||
self.imports_signal = False
|
||||
self.signal_aliases = {"signal"}
|
||||
self.os_aliases = {"os"}
|
||||
self.subprocess_aliases = {"subprocess"}
|
||||
# Maps bare function names to their fully-qualified form
|
||||
# for from-import tracking (e.g. "system" -> "os.system")
|
||||
self.shell_exec_aliases: dict[str, str] = {}
|
||||
self.loop_depth = 0
|
||||
|
||||
def visit_Import(self, node):
|
||||
|
|
@ -453,6 +759,10 @@ def _check_signal_escape_patterns(code: str):
|
|||
self.imports_signal = True
|
||||
if alias.asname:
|
||||
self.signal_aliases.add(alias.asname)
|
||||
elif alias.name == "os":
|
||||
self.os_aliases.add(alias.asname or "os")
|
||||
elif alias.name == "subprocess":
|
||||
self.subprocess_aliases.add(alias.asname or "subprocess")
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ImportFrom(self, node):
|
||||
|
|
@ -470,6 +780,16 @@ def _check_signal_escape_patterns(code: str):
|
|||
"alarm",
|
||||
):
|
||||
self.signal_aliases.add(alias.asname or alias.name)
|
||||
elif node.module in ("os", "subprocess"):
|
||||
if node.module == "os":
|
||||
self.os_aliases.add("os")
|
||||
else:
|
||||
self.subprocess_aliases.add("subprocess")
|
||||
# Track from-imports of dangerous functions
|
||||
for alias in node.names:
|
||||
fq = f"{node.module}.{alias.name}"
|
||||
if fq in _SHELL_EXEC_FUNCS:
|
||||
self.shell_exec_aliases[alias.asname or alias.name] = fq
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_While(self, node):
|
||||
|
|
@ -534,6 +854,111 @@ def _check_signal_escape_patterns(code: str):
|
|||
"description": "Modifies signal mask (may block SIGALRM)",
|
||||
}
|
||||
)
|
||||
|
||||
# --- Shell escape detection ---
|
||||
# Resolve the fully qualified function name for os.*/subprocess.*
|
||||
shell_func = None
|
||||
if isinstance(func, ast.Attribute):
|
||||
if isinstance(func.value, ast.Name):
|
||||
if func.value.id in self.os_aliases:
|
||||
shell_func = f"os.{func.attr}"
|
||||
elif func.value.id in self.subprocess_aliases:
|
||||
shell_func = f"subprocess.{func.attr}"
|
||||
elif isinstance(func, ast.Name):
|
||||
# Check from-import aliases: from os import system; system(...)
|
||||
shell_func = self.shell_exec_aliases.get(func.id)
|
||||
|
||||
if shell_func and shell_func in _SHELL_EXEC_FUNCS:
|
||||
# Expand **kwargs dicts to inspect their keys
|
||||
expanded_kwargs: dict[str, ast.AST] = {}
|
||||
has_opaque_kwargs = False
|
||||
for kw in node.keywords:
|
||||
if kw.arg is not None:
|
||||
expanded_kwargs[kw.arg] = kw.value
|
||||
elif isinstance(kw.value, ast.Dict):
|
||||
for k, v in zip(kw.value.keys, kw.value.values):
|
||||
key = _extract_string_from_node(k) if k else None
|
||||
if key is not None:
|
||||
expanded_kwargs[key] = v
|
||||
else:
|
||||
has_opaque_kwargs = True
|
||||
|
||||
cmd_kw_values = [
|
||||
v for k, v in expanded_kwargs.items() if k in _CMD_KWARGS
|
||||
]
|
||||
all_call_args = list(node.args) + cmd_kw_values
|
||||
blocked_in_args = _check_args_for_blocked(all_call_args)
|
||||
|
||||
if has_opaque_kwargs:
|
||||
# Can't inspect dynamic **kwargs -- flag as unsafe
|
||||
shell_escapes.append(
|
||||
{
|
||||
"type": "shell_escape_dynamic",
|
||||
"line": node.lineno,
|
||||
"description": (
|
||||
f"{shell_func}() called with dynamic **kwargs"
|
||||
),
|
||||
}
|
||||
)
|
||||
elif blocked_in_args:
|
||||
shell_escapes.append(
|
||||
{
|
||||
"type": "shell_escape",
|
||||
"line": node.lineno,
|
||||
"description": (
|
||||
f"{shell_func}() invokes blocked command(s): "
|
||||
f"{', '.join(sorted(blocked_in_args))}"
|
||||
),
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Only flag dynamic args for functions that interpret
|
||||
# strings as shell commands, or when shell= might be
|
||||
# enabled. Treat any non-literal-False shell= value
|
||||
# as potentially True (conservative).
|
||||
_STRING_SHELL_FUNCS = frozenset(
|
||||
{
|
||||
"os.system",
|
||||
"os.popen",
|
||||
"os.popen2",
|
||||
"os.popen3",
|
||||
"os.popen4",
|
||||
"subprocess.getoutput",
|
||||
"subprocess.getstatusoutput",
|
||||
}
|
||||
)
|
||||
shell_node = expanded_kwargs.get("shell")
|
||||
shell_safe = shell_node is None or (
|
||||
isinstance(shell_node, ast.Constant)
|
||||
and shell_node.value is False
|
||||
)
|
||||
if shell_func in _STRING_SHELL_FUNCS or not shell_safe:
|
||||
|
||||
def _is_safe_literal(n):
|
||||
if _extract_string_from_node(n) is not None:
|
||||
return True
|
||||
if isinstance(n, (ast.List, ast.Tuple)):
|
||||
return all(
|
||||
_extract_string_from_node(e) is not None
|
||||
for e in n.elts
|
||||
)
|
||||
return False
|
||||
|
||||
has_non_literal = any(
|
||||
not _is_safe_literal(a) for a in all_call_args
|
||||
)
|
||||
if has_non_literal:
|
||||
shell_escapes.append(
|
||||
{
|
||||
"type": "shell_escape_dynamic",
|
||||
"line": node.lineno,
|
||||
"description": (
|
||||
f"{shell_func}() called with non-literal "
|
||||
f"shell command (potential shell escape)"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_ExceptHandler(self, node):
|
||||
|
|
@ -549,7 +974,12 @@ def _check_signal_escape_patterns(code: str):
|
|||
}
|
||||
)
|
||||
elif isinstance(node.type, ast.Name):
|
||||
if node.type.id in ("TimeoutError", "BaseException", "Exception"):
|
||||
# Only flag BaseException and TimeoutError, NOT Exception.
|
||||
# except Exception does not catch SystemExit or
|
||||
# KeyboardInterrupt, so it cannot suppress timeout
|
||||
# enforcement. Flagging Exception causes false positives
|
||||
# on normal error-handling patterns.
|
||||
if node.type.id in ("TimeoutError", "BaseException"):
|
||||
exception_catching.append(
|
||||
{
|
||||
"type": f"catches_{node.type.id}_in_loop",
|
||||
|
|
@ -560,7 +990,7 @@ def _check_signal_escape_patterns(code: str):
|
|||
elif isinstance(node.type, ast.Tuple):
|
||||
for elt in node.type.elts:
|
||||
if isinstance(elt, ast.Name):
|
||||
if elt.id in ("TimeoutError", "BaseException", "Exception"):
|
||||
if elt.id in ("TimeoutError", "BaseException"):
|
||||
exception_catching.append(
|
||||
{
|
||||
"type": f"catches_{elt.id}_in_loop",
|
||||
|
|
@ -576,10 +1006,15 @@ def _check_signal_escape_patterns(code: str):
|
|||
if visitor.imports_signal and not signal_tampering:
|
||||
warnings.append("Code imports 'signal' module - review manually for safety")
|
||||
|
||||
is_safe = len(signal_tampering) == 0 and len(exception_catching) == 0
|
||||
is_safe = (
|
||||
len(signal_tampering) == 0
|
||||
and len(exception_catching) == 0
|
||||
and len(shell_escapes) == 0
|
||||
)
|
||||
return is_safe, {
|
||||
"signal_tampering": signal_tampering,
|
||||
"exception_catching": exception_catching,
|
||||
"shell_escapes": shell_escapes,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
|
@ -591,13 +1026,27 @@ def _check_code_safety(code: str) -> str | None:
|
|||
"""
|
||||
safe, info = _check_signal_escape_patterns(code)
|
||||
if not safe:
|
||||
# SyntaxError from ast.parse -- let these through so the subprocess
|
||||
# produces a normal Python traceback instead of a misleading
|
||||
# "unsafe code detected" message.
|
||||
if info.get("error"):
|
||||
return None
|
||||
|
||||
reasons = [
|
||||
item.get("description", "") for item in info.get("signal_tampering", [])
|
||||
]
|
||||
return (
|
||||
f"Error: unsafe code detected ({'; '.join(reasons)}). "
|
||||
f"Please remove signal manipulation from your code."
|
||||
)
|
||||
shell_reasons = [
|
||||
item.get("description", "") for item in info.get("shell_escapes", [])
|
||||
]
|
||||
exception_reasons = [
|
||||
item.get("description", "") for item in info.get("exception_catching", [])
|
||||
]
|
||||
all_reasons = [r for r in reasons + shell_reasons + exception_reasons if r]
|
||||
if all_reasons:
|
||||
return (
|
||||
f"Error: unsafe code detected ({'; '.join(all_reasons)}). "
|
||||
f"Please remove unsafe patterns from your code."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
|
@ -634,6 +1083,17 @@ def _python_exec(
|
|||
|
||||
tmp_path = None
|
||||
workdir = _get_workdir(session_id)
|
||||
# Snapshot image mtimes so we detect both new and overwritten files.
|
||||
_before: dict[str, int] = {}
|
||||
if os.path.isdir(workdir):
|
||||
for _name in os.listdir(workdir):
|
||||
if os.path.splitext(_name)[1].lower() in _IMAGE_EXTS:
|
||||
_p = os.path.join(workdir, _name)
|
||||
if os.path.isfile(_p):
|
||||
try:
|
||||
_before[_name] = os.stat(_p).st_mtime_ns
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
suffix = ".py", prefix = "studio_exec_", dir = workdir
|
||||
|
|
@ -641,13 +1101,20 @@ def _python_exec(
|
|||
with os.fdopen(fd, "w") as f:
|
||||
f.write(code)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, tmp_path],
|
||||
safe_env = _build_safe_env(workdir)
|
||||
popen_kwargs = dict(
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
cwd = workdir,
|
||||
env = safe_env,
|
||||
)
|
||||
if sys.platform != "win32":
|
||||
popen_kwargs["preexec_fn"] = _sandbox_preexec
|
||||
else:
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
|
||||
proc = subprocess.Popen([sys.executable, tmp_path], **popen_kwargs)
|
||||
|
||||
# Spawn cancel watcher if we have a cancel event
|
||||
if cancel_event is not None:
|
||||
|
|
@ -669,7 +1136,29 @@ def _python_exec(
|
|||
result = output or ""
|
||||
if proc.returncode != 0:
|
||||
result = f"Exit code {proc.returncode}:\n{result}"
|
||||
return _truncate(result) if result.strip() else "(no output)"
|
||||
result = _truncate(result) if result.strip() else "(no output)"
|
||||
|
||||
# Detect new or overwritten image files and append sentinel for frontend
|
||||
if session_id and os.path.isdir(workdir):
|
||||
new_images = []
|
||||
for _name in os.listdir(workdir):
|
||||
if os.path.splitext(_name)[1].lower() not in _IMAGE_EXTS:
|
||||
continue
|
||||
_p = os.path.join(workdir, _name)
|
||||
if not os.path.isfile(_p):
|
||||
continue
|
||||
try:
|
||||
_mtime = os.stat(_p).st_mtime_ns
|
||||
except OSError:
|
||||
continue
|
||||
if _name not in _before or _mtime != _before[_name]:
|
||||
new_images.append(_name)
|
||||
if new_images:
|
||||
import json as _json
|
||||
|
||||
result += f"\n__IMAGES__:{_json.dumps(sorted(new_images))}"
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return f"Execution error: {e}"
|
||||
|
|
@ -691,21 +1180,27 @@ def _bash_exec(
|
|||
if not command or not command.strip():
|
||||
return "No command provided."
|
||||
|
||||
# Block dangerous commands
|
||||
tokens = set(command.lower().split())
|
||||
blocked = tokens & _BASH_BLOCKED_WORDS
|
||||
# Block dangerous commands (shlex + regex based)
|
||||
blocked = _find_blocked_commands(command)
|
||||
if blocked:
|
||||
return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}"
|
||||
|
||||
try:
|
||||
workdir = _get_workdir(session_id)
|
||||
proc = subprocess.Popen(
|
||||
["bash", "-c", command],
|
||||
safe_env = _build_safe_env(workdir)
|
||||
popen_kwargs = dict(
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
cwd = workdir,
|
||||
env = safe_env,
|
||||
)
|
||||
if sys.platform != "win32":
|
||||
popen_kwargs["preexec_fn"] = _sandbox_preexec
|
||||
else:
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
|
||||
proc = subprocess.Popen(_get_shell_cmd(command), **popen_kwargs)
|
||||
|
||||
if cancel_event is not None:
|
||||
watcher = threading.Thread(
|
||||
|
|
|
|||
|
|
@ -34,37 +34,15 @@ from utils.hardware import apply_gpu_ids
|
|||
|
||||
|
||||
def _activate_transformers_version(model_name: str) -> None:
|
||||
"""Activate the correct transformers version BEFORE any ML imports.
|
||||
|
||||
If the model needs transformers 5.x, prepend the pre-installed .venv_t5/
|
||||
directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/).
|
||||
"""
|
||||
"""Activate the correct transformers version BEFORE any ML imports."""
|
||||
# Ensure backend is on path for utils imports
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
sys.path.insert(0, backend_path)
|
||||
|
||||
from utils.transformers_version import (
|
||||
needs_transformers_5,
|
||||
_resolve_base_model,
|
||||
_ensure_venv_t5_exists,
|
||||
_VENV_T5_DIR,
|
||||
)
|
||||
from utils.transformers_version import activate_transformers_for_subprocess
|
||||
|
||||
resolved = _resolve_base_model(model_name)
|
||||
if needs_transformers_5(resolved):
|
||||
if not _ensure_venv_t5_exists():
|
||||
raise RuntimeError(
|
||||
f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}"
|
||||
)
|
||||
if _VENV_T5_DIR not in sys.path:
|
||||
sys.path.insert(0, _VENV_T5_DIR)
|
||||
logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR)
|
||||
# Propagate to child subprocesses (e.g. GGUF converter)
|
||||
_pp = os.environ.get("PYTHONPATH", "")
|
||||
os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "")
|
||||
else:
|
||||
logger.info("Using default transformers (4.57.x) for %s", model_name)
|
||||
activate_transformers_for_subprocess(model_name)
|
||||
|
||||
|
||||
def _decode_image(image_base64: str):
|
||||
|
|
@ -145,6 +123,8 @@ def _get_hf_download_state(
|
|||
blobs_dirs: list[Path] = []
|
||||
|
||||
if model_names:
|
||||
from utils.paths import resolve_cached_repo_id_case
|
||||
|
||||
for name in model_names:
|
||||
if not name:
|
||||
continue
|
||||
|
|
@ -154,6 +134,7 @@ def _get_hf_download_state(
|
|||
# relative paths, and Windows paths.
|
||||
if name.startswith(("/", ".", "~")) or "\\" in name:
|
||||
continue
|
||||
name = resolve_cached_repo_id_case(name)
|
||||
# HF cache dir format: models--org--name (slashes -> --)
|
||||
cache_dir_name = "models--" + name.replace("/", "--")
|
||||
blobs_dir = cache / cache_dir_name / "blobs"
|
||||
|
|
@ -306,19 +287,21 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
except Exception as e:
|
||||
logger.warning("Could not read adapter_config.json: %s", e)
|
||||
|
||||
# Auto-enable trust_remote_code for unsloth/* transformers 5.x models
|
||||
# (matches the training worker logic in core/training/worker.py)
|
||||
# Auto-enable trust_remote_code for NemotronH/Nano models only.
|
||||
# NemotronH has config parsing bugs requiring trust_remote_code=True.
|
||||
# Other transformers 5.x models are native and do NOT need it.
|
||||
# NOTE: Must NOT match Llama-Nemotron (standard Llama architecture).
|
||||
_NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano")
|
||||
trust_remote_code = config.get("trust_remote_code", False)
|
||||
if not trust_remote_code:
|
||||
from utils.transformers_version import needs_transformers_5
|
||||
|
||||
model_name = config["model_name"]
|
||||
if needs_transformers_5(model_name) and model_name.lower().startswith(
|
||||
"unsloth/"
|
||||
_mn_lower = model_name.lower()
|
||||
if any(sub in _mn_lower for sub in _NEMOTRON_TRUST_SUBSTRINGS) and (
|
||||
_mn_lower.startswith("unsloth/") or _mn_lower.startswith("nvidia/")
|
||||
):
|
||||
trust_remote_code = True
|
||||
logger.info(
|
||||
"Auto-enabled trust_remote_code for unsloth/* transformers 5.x model: %s",
|
||||
"Auto-enabled trust_remote_code for Nemotron model: %s",
|
||||
model_name,
|
||||
)
|
||||
|
||||
|
|
|
|||
75
studio/backend/core/training/resume.py
Normal file
75
studio/backend/core/training/resume.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Helpers for validating resumable training outputs."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from utils.paths import outputs_root, resolve_output_dir
|
||||
|
||||
|
||||
def _is_under_outputs(path: Path) -> bool:
|
||||
resolved = path.resolve(strict = False)
|
||||
root = outputs_root().resolve(strict = False)
|
||||
try:
|
||||
resolved.relative_to(root)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def has_resume_state(path_value: Optional[str]) -> bool:
|
||||
if not path_value:
|
||||
return False
|
||||
return get_resume_checkpoint_path(path_value) is not None
|
||||
|
||||
|
||||
def _checkpoint_step(path: Path) -> int:
|
||||
try:
|
||||
return int(path.name.removeprefix("checkpoint-"))
|
||||
except ValueError:
|
||||
return -1
|
||||
|
||||
|
||||
def get_resume_checkpoint_path(path_value: str) -> Optional[str]:
|
||||
path = resolve_output_dir(path_value)
|
||||
if not _is_under_outputs(path) or not path.is_dir():
|
||||
return None
|
||||
if (path / "trainer_state.json").is_file():
|
||||
return str(path)
|
||||
|
||||
checkpoints = [
|
||||
child
|
||||
for child in path.glob("checkpoint-*")
|
||||
if child.is_dir() and (child / "trainer_state.json").is_file()
|
||||
]
|
||||
if not checkpoints:
|
||||
return None
|
||||
return str(max(checkpoints, key = _checkpoint_step))
|
||||
|
||||
|
||||
def normalize_resume_output_dir(path_value: str) -> str:
|
||||
path = resolve_output_dir(path_value)
|
||||
if not _is_under_outputs(path):
|
||||
raise ValueError("Resume checkpoint must be inside Studio outputs.")
|
||||
return str(path)
|
||||
|
||||
|
||||
def can_resume_run(run: dict) -> bool:
|
||||
if run.get("resumed_later"):
|
||||
return False
|
||||
|
||||
final_step = run.get("final_step")
|
||||
total_steps = run.get("total_steps")
|
||||
has_remaining_steps = (
|
||||
not isinstance(final_step, int)
|
||||
or not isinstance(total_steps, int)
|
||||
or total_steps <= 0
|
||||
or final_step < total_steps
|
||||
)
|
||||
return (
|
||||
run.get("status") == "stopped"
|
||||
and has_remaining_steps
|
||||
and has_resume_state(run.get("output_dir"))
|
||||
)
|
||||
|
|
@ -49,6 +49,7 @@ from unsloth.chat_templates import get_chat_template
|
|||
import json
|
||||
import threading
|
||||
import math
|
||||
import subprocess
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
import time
|
||||
|
|
@ -69,6 +70,11 @@ from utils.paths import (
|
|||
)
|
||||
from trl import SFTTrainer, SFTConfig
|
||||
|
||||
from utils.native_path_leases import child_env_without_native_path_secret
|
||||
from utils.subprocess_compat import (
|
||||
windows_hidden_subprocess_kwargs as _windows_hidden_subprocess_kwargs,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
|
@ -190,7 +196,11 @@ class UnslothTrainer:
|
|||
self._cuda_audio_used = False
|
||||
|
||||
# --- Detect VLM ---
|
||||
vision = is_vision_model(model_name) if not self.is_audio else False
|
||||
vision = (
|
||||
is_vision_model(model_name, hf_token = hf_token)
|
||||
if not self.is_audio
|
||||
else False
|
||||
)
|
||||
self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image
|
||||
|
||||
logger.info(
|
||||
|
|
@ -367,6 +377,7 @@ class UnslothTrainer:
|
|||
def _finalize_training(self, output_dir, label = ""):
|
||||
"""Save model after training and update progress. Used by all training branches."""
|
||||
if self.should_stop and self.save_on_stop:
|
||||
self.trainer._save_checkpoint(self.trainer.model, trial = None)
|
||||
self.trainer.save_model()
|
||||
self.tokenizer.save_pretrained(output_dir)
|
||||
self._patch_adapter_config(output_dir)
|
||||
|
|
@ -558,7 +569,11 @@ class UnslothTrainer:
|
|||
self._cuda_audio_used = False
|
||||
|
||||
# VLM: vision model with image dataset (mutually exclusive with audio paths)
|
||||
vision = is_vision_model(model_name) if not self.is_audio else False
|
||||
vision = (
|
||||
is_vision_model(model_name, hf_token = hf_token)
|
||||
if not self.is_audio
|
||||
else False
|
||||
)
|
||||
self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image
|
||||
self.model_name = model_name
|
||||
self.max_seq_length = max_seq_length
|
||||
|
|
@ -1757,6 +1772,8 @@ class UnslothTrainer:
|
|||
spark_code_dir,
|
||||
],
|
||||
check = True,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
|
||||
if spark_code_dir not in sys.path:
|
||||
|
|
@ -1974,8 +1991,6 @@ class UnslothTrainer:
|
|||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
# Clone OuteTTS repo (same as audio_codecs._load_dac)
|
||||
import subprocess
|
||||
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
outetts_code_dir = os.path.join(base_dir, "inference", "OuteTTS")
|
||||
outetts_pkg = os.path.join(outetts_code_dir, "outetts")
|
||||
|
|
@ -1992,6 +2007,8 @@ class UnslothTrainer:
|
|||
outetts_code_dir,
|
||||
],
|
||||
check = True,
|
||||
env = child_env_without_native_path_secret(),
|
||||
**_windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
for fpath in [
|
||||
os.path.join(outetts_pkg, "models", "gguf_model.py"),
|
||||
|
|
@ -2815,7 +2832,9 @@ class UnslothTrainer:
|
|||
total_steps = total, status_message = "Starting CSM training..."
|
||||
)
|
||||
logger.info(f"CSM training config: {config}\n")
|
||||
self.trainer.train()
|
||||
self.trainer.train(
|
||||
resume_from_checkpoint = training_args.get("resume_from_checkpoint")
|
||||
)
|
||||
self._finalize_training(output_dir, "CSM")
|
||||
return
|
||||
|
||||
|
|
@ -2854,7 +2873,9 @@ class UnslothTrainer:
|
|||
total_steps = total, status_message = "Starting SNAC training..."
|
||||
)
|
||||
logger.info(f"SNAC training config: {config}\n")
|
||||
self.trainer.train()
|
||||
self.trainer.train(
|
||||
resume_from_checkpoint = training_args.get("resume_from_checkpoint")
|
||||
)
|
||||
self._finalize_training(output_dir, "SNAC")
|
||||
return
|
||||
|
||||
|
|
@ -2900,7 +2921,9 @@ class UnslothTrainer:
|
|||
total_steps = total, status_message = "Starting Whisper training..."
|
||||
)
|
||||
logger.info(f"Whisper training config: {config}\n")
|
||||
self.trainer.train()
|
||||
self.trainer.train(
|
||||
resume_from_checkpoint = training_args.get("resume_from_checkpoint")
|
||||
)
|
||||
self._finalize_training(output_dir, "Whisper")
|
||||
return
|
||||
|
||||
|
|
@ -3395,7 +3418,9 @@ class UnslothTrainer:
|
|||
# ========== START TRAINING ==========
|
||||
self._update_progress(status_message = "Starting training...")
|
||||
logger.info("Starting training...\n")
|
||||
self.trainer.train()
|
||||
self.trainer.train(
|
||||
resume_from_checkpoint = training_args.get("resume_from_checkpoint")
|
||||
)
|
||||
|
||||
# ========== SAVE MODEL ==========
|
||||
self._finalize_training(output_dir)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ from typing import Optional, Tuple, Any
|
|||
|
||||
import matplotlib.pyplot as plt
|
||||
from utils.hardware import prepare_gpu_selection
|
||||
from utils.native_path_leases import (
|
||||
native_path_secret_removed_for_child_start,
|
||||
run_without_native_path_secret,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
|
@ -185,6 +189,7 @@ class TrainingBackend:
|
|||
"wandb_project": kwargs.get("wandb_project", "unsloth-training"),
|
||||
"enable_tensorboard": kwargs.get("enable_tensorboard", False),
|
||||
"tensorboard_dir": kwargs.get("tensorboard_dir", "runs"),
|
||||
"resume_from_checkpoint": kwargs.get("resume_from_checkpoint"),
|
||||
"trust_remote_code": kwargs.get("trust_remote_code", False),
|
||||
"gpu_ids": kwargs.get("gpu_ids"),
|
||||
}
|
||||
|
|
@ -212,20 +217,22 @@ class TrainingBackend:
|
|||
|
||||
from .worker import run_training_process
|
||||
|
||||
event_queue = _CTX.Queue()
|
||||
stop_queue = _CTX.Queue()
|
||||
|
||||
proc = _CTX.Process(
|
||||
target = run_training_process,
|
||||
kwargs = {
|
||||
"event_queue": event_queue,
|
||||
"stop_queue": stop_queue,
|
||||
"config": config,
|
||||
},
|
||||
daemon = True,
|
||||
)
|
||||
try:
|
||||
proc.start()
|
||||
with native_path_secret_removed_for_child_start():
|
||||
event_queue = _CTX.Queue()
|
||||
stop_queue = _CTX.Queue()
|
||||
|
||||
proc = _CTX.Process(
|
||||
target = run_without_native_path_secret,
|
||||
args = (run_training_process,),
|
||||
kwargs = {
|
||||
"event_queue": event_queue,
|
||||
"stop_queue": stop_queue,
|
||||
"config": config,
|
||||
},
|
||||
daemon = True,
|
||||
)
|
||||
proc.start()
|
||||
except Exception:
|
||||
logger.error("Failed to start training subprocess", exc_info = True)
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -16,26 +16,40 @@ from __future__ import annotations
|
|||
import structlog
|
||||
from loggers import get_logger
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import json
|
||||
import subprocess as _sp
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from utils.hardware import apply_gpu_ids
|
||||
from utils.wheel_utils import (
|
||||
direct_wheel_url,
|
||||
flash_attn_wheel_url,
|
||||
install_wheel,
|
||||
probe_torch_wheel_env,
|
||||
url_exists,
|
||||
)
|
||||
|
||||
|
||||
def _output_dir_from_resume_checkpoint(
|
||||
resume_from_checkpoint: str | None,
|
||||
) -> str | None:
|
||||
if not resume_from_checkpoint:
|
||||
return None
|
||||
path = Path(resume_from_checkpoint)
|
||||
return str(path.parent if path.name.startswith("checkpoint-") else path)
|
||||
|
||||
|
||||
_CAUSAL_CONV1D_RELEASE_TAG = "v1.6.1.post4"
|
||||
_CAUSAL_CONV1D_PACKAGE_VERSION = "1.6.1"
|
||||
_MAMBA_SSM_RELEASE_TAG = "v2.3.1"
|
||||
_MAMBA_SSM_PACKAGE_VERSION = "2.3.1"
|
||||
_FLASH_ATTN_RUNTIME_MIN_SEQ_LEN = 32768
|
||||
_FLASH_ATTN_SKIP_ENV = "UNSLOTH_STUDIO_SKIP_FLASHATTN_INSTALL"
|
||||
|
||||
|
||||
def _model_wants_causal_conv1d(model_name: str) -> bool:
|
||||
|
|
@ -45,6 +59,8 @@ def _model_wants_causal_conv1d(model_name: str) -> bool:
|
|||
for key in (
|
||||
"qwen3.5",
|
||||
"qwen3_5",
|
||||
"qwen3.6",
|
||||
"qwen3_6",
|
||||
"qwen3-next",
|
||||
"qwen3_next",
|
||||
"nemotron_h",
|
||||
|
|
@ -59,206 +75,186 @@ def _model_wants_causal_conv1d(model_name: str) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _causal_conv1d_platform_tag() -> str | None:
|
||||
machine = platform.machine().lower()
|
||||
if sys.platform.startswith("linux"):
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "linux_x86_64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "linux_aarch64"
|
||||
return None
|
||||
# No prebuilt wheels published for macOS or Windows
|
||||
return None
|
||||
|
||||
|
||||
def _probe_causal_conv1d_env() -> dict[str, str] | None:
|
||||
try:
|
||||
probe = _sp.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
(
|
||||
"import json, sys, re, torch; "
|
||||
"parts = torch.__version__.split('+', 1)[0].split('.')[:2]; "
|
||||
"minor = re.sub(r'[^0-9].*', '', parts[1]) if len(parts) > 1 else '0'; "
|
||||
"torch_mm = parts[0] + '.' + minor; "
|
||||
"print(json.dumps({"
|
||||
"'python_tag': f'cp{sys.version_info.major}{sys.version_info.minor}', "
|
||||
"'torch_mm': torch_mm, "
|
||||
"'cuda_major': str(int(str(torch.version.cuda).split('.', 1)[0])) if torch.version.cuda else '', "
|
||||
"'cxx11abi': str(torch._C._GLIBCXX_USE_CXX11_ABI).upper()"
|
||||
"}))"
|
||||
),
|
||||
],
|
||||
stdout = _sp.PIPE,
|
||||
stderr = _sp.PIPE,
|
||||
text = True,
|
||||
timeout = 30,
|
||||
)
|
||||
except _sp.TimeoutExpired:
|
||||
logger.warning("Torch environment probe timed out after 30s")
|
||||
return None
|
||||
if probe.returncode != 0:
|
||||
logger.warning(
|
||||
"Failed to probe torch environment for causal-conv1d wheel:\n%s",
|
||||
probe.stdout,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(probe.stdout.strip())
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
"Failed to parse torch environment probe output: %s", probe.stdout
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _direct_wheel_url(
|
||||
*,
|
||||
filename_prefix: str,
|
||||
package_version: str,
|
||||
release_tag: str,
|
||||
release_base_url: str,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> str | None:
|
||||
env = env or _probe_causal_conv1d_env()
|
||||
platform_tag = _causal_conv1d_platform_tag()
|
||||
if env is None or platform_tag is None or not env.get("cuda_major"):
|
||||
return None
|
||||
|
||||
filename = (
|
||||
f"{filename_prefix}-{package_version}"
|
||||
f"+cu{env['cuda_major']}torch{env['torch_mm']}"
|
||||
f"cxx11abi{env['cxx11abi']}-{env['python_tag']}-{env['python_tag']}-{platform_tag}.whl"
|
||||
)
|
||||
return f"{release_base_url}/{release_tag}/{filename}"
|
||||
|
||||
|
||||
def _url_exists(url: str) -> bool:
|
||||
try:
|
||||
request = urllib.request.Request(url, method = "HEAD")
|
||||
with urllib.request.urlopen(request, timeout = 10):
|
||||
return True
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return False
|
||||
logger.warning("Unexpected HTTP error while probing %s: %s", url, exc)
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to probe %s: %s", url, exc)
|
||||
return False
|
||||
|
||||
|
||||
def _install_package_wheel_first(
|
||||
*,
|
||||
event_queue: Any,
|
||||
import_name: str,
|
||||
display_name: str,
|
||||
pypi_name: str,
|
||||
pypi_version: str,
|
||||
filename_prefix: str,
|
||||
release_tag: str,
|
||||
release_base_url: str,
|
||||
) -> None:
|
||||
pypi_version: str | None = None,
|
||||
filename_prefix: str | None = None,
|
||||
release_tag: str | None = None,
|
||||
release_base_url: str | None = None,
|
||||
wheel_url_builder: Callable[[dict[str, str] | None], str | None] | None = None,
|
||||
pypi_spec: str | None = None,
|
||||
pypi_status_message: str | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
__import__(import_name)
|
||||
logger.info("%s already installed", display_name)
|
||||
return
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
env = _probe_causal_conv1d_env()
|
||||
wheel_url = _direct_wheel_url(
|
||||
filename_prefix = filename_prefix,
|
||||
package_version = pypi_version,
|
||||
release_tag = release_tag,
|
||||
release_base_url = release_base_url,
|
||||
env = env,
|
||||
)
|
||||
env = probe_torch_wheel_env(timeout = 30)
|
||||
if wheel_url_builder is not None:
|
||||
wheel_url = wheel_url_builder(env)
|
||||
else:
|
||||
wheel_url = direct_wheel_url(
|
||||
filename_prefix = filename_prefix,
|
||||
package_version = pypi_version,
|
||||
release_tag = release_tag,
|
||||
release_base_url = release_base_url,
|
||||
env = env,
|
||||
)
|
||||
|
||||
if wheel_url is None:
|
||||
logger.info("No compatible %s wheel candidate", display_name)
|
||||
else:
|
||||
if _url_exists(wheel_url):
|
||||
_send_status(event_queue, f"Installing prebuilt {display_name} wheel...")
|
||||
installed = False
|
||||
# Try uv first if available, then fall back to pip
|
||||
if shutil.which("uv"):
|
||||
uv_cmd = [
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"--no-deps",
|
||||
wheel_url,
|
||||
]
|
||||
result = _sp.run(
|
||||
uv_cmd,
|
||||
stdout = _sp.PIPE,
|
||||
stderr = _sp.STDOUT,
|
||||
text = True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
installed = True
|
||||
else:
|
||||
logger.warning(
|
||||
"uv failed to install %s wheel:\n%s",
|
||||
display_name,
|
||||
result.stdout,
|
||||
)
|
||||
if not installed:
|
||||
pip_cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--no-deps",
|
||||
wheel_url,
|
||||
]
|
||||
result = _sp.run(
|
||||
pip_cmd,
|
||||
stdout = _sp.PIPE,
|
||||
stderr = _sp.STDOUT,
|
||||
text = True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
installed = True
|
||||
else:
|
||||
logger.warning(
|
||||
"pip failed to install %s wheel:\n%s",
|
||||
display_name,
|
||||
result.stdout,
|
||||
)
|
||||
if installed:
|
||||
elif url_exists(wheel_url):
|
||||
_send_status(event_queue, f"Installing prebuilt {display_name} wheel...")
|
||||
for installer, result in install_wheel(
|
||||
wheel_url,
|
||||
python_executable = sys.executable,
|
||||
use_uv = bool(shutil.which("uv")),
|
||||
run = _sp.run,
|
||||
):
|
||||
if result.returncode == 0:
|
||||
logger.info("Installed prebuilt %s wheel successfully", display_name)
|
||||
return
|
||||
return True
|
||||
logger.warning(
|
||||
"%s failed to install %s wheel:\n%s",
|
||||
installer,
|
||||
display_name,
|
||||
result.stdout,
|
||||
)
|
||||
else:
|
||||
logger.info("No published %s wheel found: %s", display_name, wheel_url)
|
||||
|
||||
is_hip = env and env.get("hip_version")
|
||||
if is_hip and not shutil.which("hipcc"):
|
||||
logger.error(
|
||||
"%s requires hipcc for source compilation on ROCm. "
|
||||
"Install the ROCm HIP SDK: https://rocm.docs.amd.com",
|
||||
display_name,
|
||||
)
|
||||
_send_status(
|
||||
event_queue,
|
||||
f"{display_name}: hipcc not found (ROCm HIP SDK required)",
|
||||
)
|
||||
return False
|
||||
|
||||
if pypi_spec is None:
|
||||
pypi_spec = f"{pypi_name}=={pypi_version}"
|
||||
|
||||
if pypi_status_message is None:
|
||||
if is_hip:
|
||||
pypi_status_message = (
|
||||
f"Compiling {display_name} from source for ROCm "
|
||||
"(this may take several minutes)..."
|
||||
)
|
||||
else:
|
||||
logger.info("No published %s wheel found: %s", display_name, wheel_url)
|
||||
pypi_status_message = f"Installing {display_name} from PyPI..."
|
||||
|
||||
_send_status(event_queue, pypi_status_message)
|
||||
|
||||
# Prefer uv for faster dependency resolution when available
|
||||
plain_pypi_install = pypi_version is None
|
||||
if plain_pypi_install:
|
||||
if shutil.which("uv"):
|
||||
pypi_cmd = [
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
pypi_spec,
|
||||
]
|
||||
else:
|
||||
pypi_cmd = [sys.executable, "-m", "pip", "install", pypi_spec]
|
||||
else:
|
||||
if shutil.which("uv"):
|
||||
pypi_cmd = [
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"--no-build-isolation",
|
||||
"--no-deps",
|
||||
]
|
||||
# Avoid stale cache artifacts from partial HIP source builds
|
||||
if is_hip:
|
||||
pypi_cmd.append("--no-cache")
|
||||
pypi_cmd.append(pypi_spec)
|
||||
else:
|
||||
pypi_cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--no-build-isolation",
|
||||
"--no-deps",
|
||||
"--no-cache-dir",
|
||||
pypi_spec,
|
||||
]
|
||||
|
||||
# Source compilation on ROCm can take 10-30 minutes; use a generous
|
||||
# timeout. Non-HIP installs preserve the pre-existing "no timeout"
|
||||
# behaviour so unrelated slow installs (e.g. causal-conv1d source
|
||||
# build on Linux aarch64 or unsupported torch/CUDA combinations)
|
||||
# are not aborted at 5 minutes by this PR.
|
||||
_run_kwargs: dict[str, Any] = {
|
||||
"stdout": _sp.PIPE,
|
||||
"stderr": _sp.STDOUT,
|
||||
"text": True,
|
||||
}
|
||||
if is_hip:
|
||||
_run_kwargs["timeout"] = 1800
|
||||
|
||||
try:
|
||||
result = _sp.run(pypi_cmd, **_run_kwargs)
|
||||
except _sp.TimeoutExpired:
|
||||
logger.error(
|
||||
"%s installation timed out after %ds",
|
||||
display_name,
|
||||
_run_kwargs.get("timeout"),
|
||||
)
|
||||
_send_status(
|
||||
event_queue,
|
||||
f"{display_name} installation timed out after "
|
||||
f"{_run_kwargs.get('timeout')}s",
|
||||
)
|
||||
return False
|
||||
|
||||
_send_status(event_queue, f"Installing {display_name} from PyPI...")
|
||||
pypi_cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--no-build-isolation",
|
||||
"--no-deps",
|
||||
"--no-cache-dir",
|
||||
f"{pypi_name}=={pypi_version}",
|
||||
]
|
||||
result = _sp.run(
|
||||
pypi_cmd,
|
||||
stdout = _sp.PIPE,
|
||||
stderr = _sp.STDOUT,
|
||||
text = True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("Failed to install %s from PyPI:\n%s", display_name, result.stdout)
|
||||
return
|
||||
if is_hip:
|
||||
# Surface a clear error for ROCm source build failures
|
||||
error_lines = (result.stdout or "").strip().splitlines()
|
||||
snippet = "\n".join(error_lines[-5:]) if error_lines else "(no output)"
|
||||
logger.error(
|
||||
"Failed to compile %s for ROCm:\n%s",
|
||||
display_name,
|
||||
result.stdout,
|
||||
)
|
||||
_send_status(
|
||||
event_queue,
|
||||
f"Failed to compile {display_name} for ROCm. "
|
||||
"Check that hipcc and ROCm development headers are installed.\n"
|
||||
f"{snippet}",
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to install %s from PyPI:\n%s",
|
||||
display_name,
|
||||
result.stdout,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info("Installed %s from PyPI", display_name)
|
||||
if is_hip:
|
||||
logger.info("Compiled and installed %s from source for ROCm", display_name)
|
||||
else:
|
||||
logger.info("Installed %s from PyPI", display_name)
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_causal_conv1d_fast_path(event_queue: Any, model_name: str) -> None:
|
||||
|
|
@ -305,38 +301,41 @@ def _ensure_mamba_ssm(event_queue: Any, model_name: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _activate_transformers_version(model_name: str) -> None:
|
||||
"""Activate the correct transformers version BEFORE any ML imports.
|
||||
def _should_try_runtime_flash_attn_install(max_seq_length: int) -> bool:
|
||||
if os.getenv(_FLASH_ATTN_SKIP_ENV) == "1":
|
||||
return False
|
||||
if max_seq_length < _FLASH_ATTN_RUNTIME_MIN_SEQ_LEN:
|
||||
return False
|
||||
return sys.platform.startswith("linux")
|
||||
|
||||
If the model needs transformers 5.x, prepend the pre-installed .venv_t5/
|
||||
directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/).
|
||||
"""
|
||||
|
||||
def _ensure_flash_attn_for_long_context(event_queue: Any, max_seq_length: int) -> None:
|
||||
if not _should_try_runtime_flash_attn_install(max_seq_length):
|
||||
return
|
||||
|
||||
installed = _install_package_wheel_first(
|
||||
event_queue = event_queue,
|
||||
import_name = "flash_attn",
|
||||
display_name = "flash-attn",
|
||||
pypi_name = "flash-attn",
|
||||
wheel_url_builder = flash_attn_wheel_url,
|
||||
pypi_spec = "flash-attn",
|
||||
pypi_status_message = "Installing flash-attn from PyPI for long-context training...",
|
||||
)
|
||||
if not installed:
|
||||
_send_status(event_queue, "Continuing without flash-attn")
|
||||
|
||||
|
||||
def _activate_transformers_version(model_name: str) -> None:
|
||||
"""Activate the correct transformers version BEFORE any ML imports."""
|
||||
# Ensure backend is on path for utils imports
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
sys.path.insert(0, backend_path)
|
||||
|
||||
from utils.transformers_version import (
|
||||
needs_transformers_5,
|
||||
_resolve_base_model,
|
||||
_ensure_venv_t5_exists,
|
||||
_VENV_T5_DIR,
|
||||
)
|
||||
from utils.transformers_version import activate_transformers_for_subprocess
|
||||
|
||||
resolved = _resolve_base_model(model_name)
|
||||
if needs_transformers_5(resolved):
|
||||
if not _ensure_venv_t5_exists():
|
||||
raise RuntimeError(
|
||||
f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}"
|
||||
)
|
||||
if _VENV_T5_DIR not in sys.path:
|
||||
sys.path.insert(0, _VENV_T5_DIR)
|
||||
logger.info("Activated transformers 5.x from %s", _VENV_T5_DIR)
|
||||
# Propagate to child subprocesses (e.g. GGUF converter)
|
||||
_pp = os.environ.get("PYTHONPATH", "")
|
||||
os.environ["PYTHONPATH"] = _VENV_T5_DIR + (os.pathsep + _pp if _pp else "")
|
||||
else:
|
||||
logger.info("Using default transformers (4.57.x) for %s", model_name)
|
||||
activate_transformers_for_subprocess(model_name)
|
||||
|
||||
|
||||
def run_training_process(
|
||||
|
|
@ -386,20 +385,22 @@ def run_training_process(
|
|||
)
|
||||
return
|
||||
|
||||
# ── 1a. Auto-enable trust_remote_code for unsloth/* transformers 5.x models ──
|
||||
# Some newer architectures (e.g. NemotronH) have config parsing bugs in
|
||||
# transformers that require trust_remote_code=True as a workaround.
|
||||
# Only auto-enable for unsloth/* prefixed models (trusted source).
|
||||
from utils.transformers_version import needs_transformers_5
|
||||
|
||||
# ── 1a. Auto-enable trust_remote_code for NemotronH/Nano models ──
|
||||
# NemotronH has config parsing bugs in transformers that require
|
||||
# trust_remote_code=True as a workaround. Other transformers 5.x models
|
||||
# (Qwen3.5, Gemma 4, etc.) are native and do NOT need it — enabling it
|
||||
# bypasses the compiler (disabling fused CE).
|
||||
# NOTE: Must NOT match Llama-Nemotron (standard Llama architecture).
|
||||
_NEMOTRON_TRUST_SUBSTRINGS = ("nemotron_h", "nemotron-h", "nemotron-3-nano")
|
||||
_lowered = model_name.lower()
|
||||
if (
|
||||
needs_transformers_5(model_name)
|
||||
and model_name.lower().startswith("unsloth/")
|
||||
any(sub in _lowered for sub in _NEMOTRON_TRUST_SUBSTRINGS)
|
||||
and (_lowered.startswith("unsloth/") or _lowered.startswith("nvidia/"))
|
||||
and not config.get("trust_remote_code", False)
|
||||
):
|
||||
config["trust_remote_code"] = True
|
||||
logger.info(
|
||||
"Auto-enabled trust_remote_code for unsloth/* transformers 5.x model: %s",
|
||||
"Auto-enabled trust_remote_code for Nemotron model: %s",
|
||||
model_name,
|
||||
)
|
||||
|
||||
|
|
@ -407,6 +408,10 @@ def run_training_process(
|
|||
try:
|
||||
_ensure_causal_conv1d_fast_path(event_queue, model_name)
|
||||
_ensure_mamba_ssm(event_queue, model_name)
|
||||
_ensure_flash_attn_for_long_context(
|
||||
event_queue,
|
||||
int(config.get("max_seq_length", 2048)),
|
||||
)
|
||||
except Exception as exc:
|
||||
event_queue.put(
|
||||
{
|
||||
|
|
@ -761,7 +766,10 @@ def run_training_process(
|
|||
return
|
||||
|
||||
# Generate output dir
|
||||
output_dir = config.get("output_dir")
|
||||
resume_from_checkpoint = config.get("resume_from_checkpoint")
|
||||
output_dir = config.get("output_dir") or _output_dir_from_resume_checkpoint(
|
||||
resume_from_checkpoint
|
||||
)
|
||||
if not output_dir:
|
||||
output_dir = f"{model_name.replace('/', '_')}_{int(time.time())}"
|
||||
output_dir = str(resolve_output_dir(output_dir))
|
||||
|
|
@ -809,6 +817,7 @@ def run_training_process(
|
|||
max_seq_length = config.get("max_seq_length", 2048),
|
||||
optim = config.get("optim", "adamw_8bit"),
|
||||
lr_scheduler_type = config.get("lr_scheduler_type", "linear"),
|
||||
resume_from_checkpoint = resume_from_checkpoint,
|
||||
)
|
||||
|
||||
_tqdm_stop.set()
|
||||
|
|
@ -825,10 +834,13 @@ def run_training_process(
|
|||
}
|
||||
)
|
||||
else:
|
||||
saved_output_dir = (
|
||||
None if trainer.should_stop and not trainer.save_on_stop else output_dir
|
||||
)
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "complete",
|
||||
"output_dir": output_dir,
|
||||
"output_dir": saved_output_dir,
|
||||
"status_message": progress.status_message or "Training completed",
|
||||
"ts": time.time(),
|
||||
}
|
||||
|
|
@ -1113,11 +1125,15 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
)
|
||||
return
|
||||
|
||||
output_dir = config.get("output_dir")
|
||||
resume_from_checkpoint = config.get("resume_from_checkpoint")
|
||||
output_dir = config.get("output_dir") or _output_dir_from_resume_checkpoint(
|
||||
resume_from_checkpoint
|
||||
)
|
||||
if not output_dir:
|
||||
output_dir = str(
|
||||
resolve_output_dir(f"{model_name.replace('/', '_')}_{int(time.time())}")
|
||||
)
|
||||
output_dir = str(resolve_output_dir(output_dir))
|
||||
|
||||
num_epochs = config.get("num_epochs", 2)
|
||||
batch_size = config.get("batch_size", 256)
|
||||
|
|
@ -1225,7 +1241,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
callbacks = [_EmbeddingProgressCallback()],
|
||||
)
|
||||
|
||||
trainer.train()
|
||||
trainer.train(resume_from_checkpoint = resume_from_checkpoint)
|
||||
except Exception as e:
|
||||
event_queue.put(
|
||||
{
|
||||
|
|
@ -1251,6 +1267,8 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
|
||||
_send_status(event_queue, "Saving model...")
|
||||
try:
|
||||
if _should_stop and _save_on_stop:
|
||||
trainer._save_checkpoint(trainer.model, trial = None)
|
||||
model.save_pretrained(output_dir)
|
||||
model.tokenizer.save_pretrained(output_dir)
|
||||
logger.info("Embedding model saved to %s", output_dir)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ from typing import Optional
|
|||
|
||||
import structlog
|
||||
|
||||
from loggers.handlers import filter_sensitive_data
|
||||
|
||||
|
||||
class LogConfig:
|
||||
"""Structured logging configuration for the application.
|
||||
|
|
@ -44,12 +46,22 @@ class LogConfig:
|
|||
# Fallback to INFO if an invalid level is provided
|
||||
log_level = getattr(logging, log_level_name, logging.INFO)
|
||||
|
||||
if sys.platform == "win32":
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
try:
|
||||
stream.reconfigure(encoding = "utf-8", errors = "replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
structlog.configure(
|
||||
processors = [
|
||||
# Reorder processors to control field order
|
||||
structlog.processors.TimeStamper(fmt = "iso"), # timestamp first
|
||||
structlog.processors.add_log_level, # level second
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.format_exc_info,
|
||||
filter_sensitive_data,
|
||||
# Custom processor to flatten the extra field
|
||||
lambda logger, method_name, event_dict: {
|
||||
"timestamp": event_dict.get("timestamp"),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Key Components:
|
|||
- get_logger: Factory function for structured loggers
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
|
@ -22,7 +23,12 @@ import structlog
|
|||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from utils.native_path_leases import redact_native_paths
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
_NATIVE_PATH_LEASE_RE = re.compile(
|
||||
r"(?i)(\b(?:native_path_lease|nativePathLease)[\"']?\s*[:=]\s*[\"']?)[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"
|
||||
)
|
||||
|
||||
|
||||
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
|
|
@ -75,6 +81,12 @@ def filter_sensitive_data(logger, method_name, event_dict):
|
|||
"""Structlog processor to filter out base64 data from logs."""
|
||||
|
||||
def filter_value(value):
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = redact_native_paths(value)
|
||||
except Exception:
|
||||
pass
|
||||
value = _NATIVE_PATH_LEASE_RE.sub(r"\1<redacted native path lease>", value)
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and len(value) > 100
|
||||
|
|
@ -83,12 +95,22 @@ def filter_sensitive_data(logger, method_name, event_dict):
|
|||
# Likely base64 data, truncate it
|
||||
return value[:20] + "..."
|
||||
elif isinstance(value, dict):
|
||||
return {k: filter_value(v) for k, v in value.items()}
|
||||
return {
|
||||
k: "<redacted native path lease>"
|
||||
if str(k).replace("_", "").lower() == "nativepathlease"
|
||||
else filter_value(v)
|
||||
for k, v in value.items()
|
||||
}
|
||||
elif isinstance(value, list):
|
||||
return [filter_value(item) for item in value]
|
||||
return value
|
||||
|
||||
return {k: filter_value(v) for k, v in event_dict.items()}
|
||||
return {
|
||||
k: "<redacted native path lease>"
|
||||
if str(k).replace("_", "").lower() == "nativepathlease"
|
||||
else filter_value(v)
|
||||
for k, v in event_dict.items()
|
||||
}
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.BoundLogger:
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import mimetypes
|
|||
import shutil
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
|
||||
# Fix broken Windows registry MIME types. Some Windows installs map .js to
|
||||
# "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes
|
||||
|
|
@ -61,6 +62,7 @@ from routes import (
|
|||
datasets_router,
|
||||
export_router,
|
||||
inference_router,
|
||||
inference_studio_router,
|
||||
models_router,
|
||||
providers_router,
|
||||
training_history_router,
|
||||
|
|
@ -77,6 +79,28 @@ from utils.hardware import (
|
|||
import utils.hardware.hardware as _hw_module
|
||||
|
||||
from utils.cache_cleanup import clear_unsloth_compiled_cache
|
||||
from utils.native_path_leases import native_path_leases_supported
|
||||
|
||||
|
||||
def get_unsloth_version() -> str:
|
||||
try:
|
||||
return package_version("unsloth")
|
||||
except PackageNotFoundError:
|
||||
pass
|
||||
|
||||
version_file = (
|
||||
_Path(__file__).resolve().parents[2] / "unsloth" / "models" / "_utils.py"
|
||||
)
|
||||
try:
|
||||
for line in version_file.read_text(encoding = "utf-8").splitlines():
|
||||
if line.startswith("__version__ = "):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
except OSError:
|
||||
pass
|
||||
return "dev"
|
||||
|
||||
|
||||
UNSLOTH_VERSION = get_unsloth_version()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -146,7 +170,7 @@ async def lifespan(app: FastAPI):
|
|||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title = "Unsloth UI Backend",
|
||||
version = "1.0.0",
|
||||
version = UNSLOTH_VERSION,
|
||||
description = "Backend API for Unsloth UI - Training and Model Management",
|
||||
lifespan = lifespan,
|
||||
)
|
||||
|
|
@ -163,9 +187,24 @@ logger = LogConfig.setup_logging(
|
|||
app.add_middleware(LoggingMiddleware)
|
||||
|
||||
# CORS middleware
|
||||
_api_only = os.environ.get("UNSLOTH_API_ONLY") == "1"
|
||||
_cors_origins = ["*"]
|
||||
if _api_only:
|
||||
_cors_origins = [
|
||||
"tauri://localhost", # Linux/macOS Tauri webview
|
||||
"http://tauri.localhost", # Windows Tauri webview
|
||||
"http://localhost", # dev fallback
|
||||
"http://localhost:5173", # Tauri dev/Vite
|
||||
"http://127.0.0.1:5173", # Tauri dev/Vite fallback
|
||||
]
|
||||
_cors_origin_regex = None
|
||||
else:
|
||||
_cors_origin_regex = None
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins = ["*"], # In production, specify allowed origins
|
||||
allow_origins = _cors_origins,
|
||||
allow_origin_regex = _cors_origin_regex,
|
||||
allow_credentials = True,
|
||||
allow_methods = ["*"],
|
||||
allow_headers = ["*"],
|
||||
|
|
@ -178,6 +217,9 @@ app.include_router(auth_router, prefix = "/api/auth", tags = ["auth"])
|
|||
app.include_router(training_router, prefix = "/api/train", tags = ["training"])
|
||||
app.include_router(models_router, prefix = "/api/models", tags = ["models"])
|
||||
app.include_router(inference_router, prefix = "/api/inference", tags = ["inference"])
|
||||
# Studio-only inference endpoints (cancel, etc.) are intentionally NOT
|
||||
# exposed on the /v1 OpenAI-compat prefix below.
|
||||
app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["inference"])
|
||||
|
||||
# OpenAI-compatible endpoints: mount the same inference router at /v1
|
||||
# so external tools (Open WebUI, SillyTavern, etc.) can use the
|
||||
|
|
@ -205,8 +247,12 @@ async def health_check():
|
|||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"service": "Unsloth UI Backend",
|
||||
"version": UNSLOTH_VERSION,
|
||||
"device_type": device_type,
|
||||
"chat_only": _hw_module.CHAT_ONLY,
|
||||
"desktop_protocol_version": 1,
|
||||
"supports_desktop_auth": True,
|
||||
"native_path_leases_supported": native_path_leases_supported(),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -244,6 +290,7 @@ async def get_system_info():
|
|||
import platform
|
||||
import psutil
|
||||
from utils.hardware import get_device
|
||||
from utils.hardware.hardware import _backend_label
|
||||
|
||||
visibility_info = get_backend_visible_gpu_info()
|
||||
gpu_info = {
|
||||
|
|
@ -257,7 +304,10 @@ async def get_system_info():
|
|||
return {
|
||||
"platform": platform.platform(),
|
||||
"python_version": platform.python_version(),
|
||||
"device_backend": get_device().value,
|
||||
# Use the centralized _backend_label helper so the /api/system
|
||||
# endpoint reports "rocm" on AMD hosts instead of "cuda", matching
|
||||
# the /api/hardware and /api/gpu-visibility endpoints.
|
||||
"device_backend": _backend_label(get_device()),
|
||||
"cpu_count": psutil.cpu_count(),
|
||||
"memory": {
|
||||
"total_gb": round(memory.total / 1e9, 2),
|
||||
|
|
@ -356,7 +406,7 @@ def setup_frontend(app: FastAPI, build_path: Path):
|
|||
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_frontend(full_path: str):
|
||||
if full_path.startswith("api"):
|
||||
if full_path in {"api", "v1"} or full_path.startswith(("api/", "v1/")):
|
||||
return {"error": "API endpoint not found"}
|
||||
|
||||
file_path = (build_path / full_path).resolve()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
Pydantic schemas for Authentication API
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
|
@ -15,6 +17,12 @@ class AuthLoginRequest(BaseModel):
|
|||
password: str = Field(..., description = "Password")
|
||||
|
||||
|
||||
class DesktopLoginRequest(BaseModel):
|
||||
"""Desktop-only local secret exchange payload."""
|
||||
|
||||
secret: str = Field(..., description = "Desktop local auth secret")
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Refresh token payload to obtain new access + refresh tokens."""
|
||||
|
||||
|
|
@ -45,3 +53,44 @@ class ChangePasswordRequest(BaseModel):
|
|||
new_password: str = Field(
|
||||
..., min_length = 8, description = "Replacement password (minimum 8 characters)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API key schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CreateApiKeyRequest(BaseModel):
|
||||
"""Request body to create a new API key."""
|
||||
|
||||
name: str = Field(..., description = "Human-readable label for this key")
|
||||
expires_in_days: Optional[int] = Field(
|
||||
None, description = "Number of days until the key expires (None = never)"
|
||||
)
|
||||
|
||||
|
||||
class ApiKeyResponse(BaseModel):
|
||||
"""Public representation of an API key (never contains the raw key)."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
key_prefix: str = Field(
|
||||
..., description = "First 8 characters after sk-unsloth- for display"
|
||||
)
|
||||
created_at: str
|
||||
last_used_at: Optional[str] = None
|
||||
expires_at: Optional[str] = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class CreateApiKeyResponse(BaseModel):
|
||||
"""Returned once when a key is created -- ``key`` is never shown again."""
|
||||
|
||||
key: str = Field(..., description = "Full API key (shown once)")
|
||||
api_key: ApiKeyResponse
|
||||
|
||||
|
||||
class ApiKeyListResponse(BaseModel):
|
||||
"""List of API keys for the authenticated user."""
|
||||
|
||||
api_keys: list[ApiKeyResponse]
|
||||
|
|
|
|||
|
|
@ -11,13 +11,16 @@ import time
|
|||
import uuid
|
||||
from typing import Annotated, Any, Dict, Literal, Optional, List, Union
|
||||
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag
|
||||
from pydantic import BaseModel, Discriminator, Field, Tag, model_validator
|
||||
|
||||
|
||||
class LoadRequest(BaseModel):
|
||||
"""Request to load a model for inference"""
|
||||
|
||||
model_path: str = Field(..., description = "Model identifier or local path")
|
||||
native_path_lease: Optional[str] = Field(
|
||||
None, description = "Frontend-visible signed native path grant"
|
||||
)
|
||||
hf_token: Optional[str] = Field(
|
||||
None, description = "HuggingFace token for gated models"
|
||||
)
|
||||
|
|
@ -48,6 +51,20 @@ class LoadRequest(BaseModel):
|
|||
None,
|
||||
description = "Physical GPU indices to use, for example [0, 1]. Omit or pass [] to use automatic selection. Explicit gpu_ids are unsupported when the parent CUDA_VISIBLE_DEVICES uses UUID/MIG entries. Not supported for GGUF models.",
|
||||
)
|
||||
speculative_type: Optional[str] = Field(
|
||||
None,
|
||||
description = "Speculative decoding mode for GGUF models (e.g. 'ngram-simple', 'ngram-mod'). Ignored for non-GGUF and vision models.",
|
||||
)
|
||||
llama_extra_args: Optional[List[str]] = Field(
|
||||
None,
|
||||
description = (
|
||||
"Extra arguments forwarded verbatim to llama-server for GGUF models. "
|
||||
"One token per list entry, e.g. ['--top-k', '20', '--seed', '42']. "
|
||||
"Studio-managed flags (model identity, port, context length, GPU placement, "
|
||||
"auth, --flash-attn, --no-context-shift, --jinja) are rejected. Ignored for "
|
||||
"non-GGUF models."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class UnloadRequest(BaseModel):
|
||||
|
|
@ -65,6 +82,9 @@ class ValidateModelRequest(BaseModel):
|
|||
"""
|
||||
|
||||
model_path: str = Field(..., description = "Model identifier or local path")
|
||||
native_path_lease: Optional[str] = Field(
|
||||
None, description = "Frontend-visible signed native path grant"
|
||||
)
|
||||
hf_token: Optional[str] = Field(
|
||||
None, description = "HuggingFace token for gated models"
|
||||
)
|
||||
|
|
@ -90,6 +110,10 @@ class ValidateModelResponse(BaseModel):
|
|||
is_gguf: bool = Field(False, description = "Whether this is a GGUF model (llama.cpp)")
|
||||
is_lora: bool = Field(False, description = "Whether this is a LoRA adapter")
|
||||
is_vision: bool = Field(False, description = "Whether this is a vision-capable model")
|
||||
requires_trust_remote_code: bool = Field(
|
||||
False,
|
||||
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
|
||||
)
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
|
|
@ -133,6 +157,10 @@ class LoadResponse(BaseModel):
|
|||
inference: dict = Field(
|
||||
..., description = "Inference parameters (temperature, top_p, top_k, min_p)"
|
||||
)
|
||||
requires_trust_remote_code: bool = Field(
|
||||
False,
|
||||
description = "Whether the model defaults require trust_remote_code to be enabled for loading.",
|
||||
)
|
||||
context_length: Optional[int] = Field(
|
||||
None, description = "Model's native context length (from GGUF metadata)"
|
||||
)
|
||||
|
|
@ -145,12 +173,20 @@ class LoadResponse(BaseModel):
|
|||
)
|
||||
supports_reasoning: bool = Field(
|
||||
False,
|
||||
description = "Whether model supports thinking/reasoning mode (enable_thinking)",
|
||||
description = "Whether model supports thinking/reasoning mode (enable_thinking or reasoning_effort)",
|
||||
)
|
||||
reasoning_style: Literal["enable_thinking", "reasoning_effort"] = Field(
|
||||
"enable_thinking",
|
||||
description = "Reasoning control style: 'enable_thinking' (boolean) or 'reasoning_effort' (low|medium|high)",
|
||||
)
|
||||
reasoning_always_on: bool = Field(
|
||||
False,
|
||||
description = "Whether reasoning is always on (hardcoded <think> tags, not toggleable)",
|
||||
)
|
||||
supports_preserve_thinking: bool = Field(
|
||||
False,
|
||||
description = "Whether the template understands the optional preserve_thinking kwarg (Qwen3.6-style)",
|
||||
)
|
||||
supports_tools: bool = Field(
|
||||
False,
|
||||
description = "Whether model supports tool calling (web search, etc.)",
|
||||
|
|
@ -163,6 +199,10 @@ class LoadResponse(BaseModel):
|
|||
None,
|
||||
description = "Jinja2 chat template string (from GGUF metadata or tokenizer)",
|
||||
)
|
||||
speculative_type: Optional[str] = Field(
|
||||
None,
|
||||
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
|
||||
)
|
||||
|
||||
|
||||
class UnloadResponse(BaseModel):
|
||||
|
|
@ -172,6 +212,39 @@ class UnloadResponse(BaseModel):
|
|||
model: str = Field(..., description = "Model identifier that was unloaded")
|
||||
|
||||
|
||||
class LoadProgressResponse(BaseModel):
|
||||
"""Progress of the active GGUF load, sampled on demand.
|
||||
|
||||
Used by the UI to show a real progress bar during the
|
||||
post-download warmup window (mmap + CUDA upload), rather than a
|
||||
generic "Starting model..." spinner that freezes for minutes on
|
||||
large MoE models.
|
||||
"""
|
||||
|
||||
phase: Optional[str] = Field(
|
||||
None,
|
||||
description = (
|
||||
"Load phase: 'mmap' (weights paging into RAM via mmap), "
|
||||
"'ready' (llama-server reported healthy), or null when no "
|
||||
"load is in flight."
|
||||
),
|
||||
)
|
||||
bytes_loaded: int = Field(
|
||||
0,
|
||||
description = (
|
||||
"Bytes of the model already resident in the llama-server "
|
||||
"process (VmRSS on Linux)."
|
||||
),
|
||||
)
|
||||
bytes_total: int = Field(
|
||||
0,
|
||||
description = "Total bytes across all GGUF shards for the active model.",
|
||||
)
|
||||
fraction: float = Field(
|
||||
0.0, description = "bytes_loaded / bytes_total, clamped to 0..1."
|
||||
)
|
||||
|
||||
|
||||
class InferenceStatusResponse(BaseModel):
|
||||
"""Current inference backend status"""
|
||||
|
||||
|
|
@ -205,15 +278,31 @@ class InferenceStatusResponse(BaseModel):
|
|||
inference: Optional[Dict[str, Any]] = Field(
|
||||
None, description = "Recommended inference parameters for the active model"
|
||||
)
|
||||
requires_trust_remote_code: bool = Field(
|
||||
False,
|
||||
description = "Whether the active model requires trust_remote_code to be enabled for loading.",
|
||||
)
|
||||
supports_reasoning: bool = Field(
|
||||
False, description = "Whether the active model supports reasoning/thinking mode"
|
||||
)
|
||||
reasoning_style: Literal["enable_thinking", "reasoning_effort"] = Field(
|
||||
"enable_thinking",
|
||||
description = "Reasoning control style: 'enable_thinking' (boolean) or 'reasoning_effort' (low|medium|high)",
|
||||
)
|
||||
reasoning_always_on: bool = Field(
|
||||
False, description = "Whether reasoning is always on (not toggleable)"
|
||||
)
|
||||
supports_preserve_thinking: bool = Field(
|
||||
False,
|
||||
description = "Whether the active model's template understands the optional preserve_thinking kwarg",
|
||||
)
|
||||
supports_tools: bool = Field(
|
||||
False, description = "Whether the active model supports tool calling"
|
||||
)
|
||||
chat_template: Optional[str] = Field(
|
||||
None,
|
||||
description = "Jinja2 chat template string for the active model",
|
||||
)
|
||||
context_length: Optional[int] = Field(
|
||||
None, description = "Context length of the active model"
|
||||
)
|
||||
|
|
@ -225,6 +314,10 @@ class InferenceStatusResponse(BaseModel):
|
|||
None,
|
||||
description = "Model's native context length from GGUF metadata (not capped by VRAM)",
|
||||
)
|
||||
speculative_type: Optional[str] = Field(
|
||||
None,
|
||||
description = "Active speculative decoding mode (e.g. 'ngram-simple', 'ngram-mod'), or None if disabled",
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
@ -281,14 +374,69 @@ class ChatMessage(BaseModel):
|
|||
|
||||
``content`` may be a plain string (text-only) or a list of
|
||||
content parts for multimodal messages (OpenAI vision format).
|
||||
Assistant messages that only contain tool calls may set ``content``
|
||||
to ``None`` with ``tool_calls`` populated. ``role="tool"`` messages
|
||||
carry the result of a client-executed tool call and require
|
||||
``tool_call_id`` per the OpenAI spec.
|
||||
"""
|
||||
|
||||
role: Literal["system", "user", "assistant"] = Field(
|
||||
role: Literal["system", "user", "assistant", "tool"] = Field(
|
||||
..., description = "Message role"
|
||||
)
|
||||
content: Union[str, list[ContentPart]] = Field(
|
||||
..., description = "Message content (string or multimodal parts)"
|
||||
content: Optional[Union[str, list[ContentPart]]] = Field(
|
||||
None, description = "Message content (string or multimodal parts)"
|
||||
)
|
||||
tool_call_id: Optional[str] = Field(
|
||||
None,
|
||||
description = "OpenAI tool-result messages: id of the tool call this result belongs to.",
|
||||
)
|
||||
tool_calls: Optional[list[dict]] = Field(
|
||||
None,
|
||||
description = "OpenAI assistant messages: structured tool calls the model decided to make.",
|
||||
)
|
||||
name: Optional[str] = Field(
|
||||
None,
|
||||
description = "OpenAI tool-result messages: name of the tool whose result this is.",
|
||||
)
|
||||
|
||||
@model_validator(mode = "after")
|
||||
def _validate_role_shape(self) -> "ChatMessage":
|
||||
# Enforce the per-role OpenAI spec shape at the request boundary.
|
||||
# Without this, malformed messages (e.g. user entries with no
|
||||
# content, tool_calls on a user/system role, role="tool" without
|
||||
# tool_call_id) would be silently forwarded to llama-server via
|
||||
# the passthrough path, surfacing as opaque upstream errors or
|
||||
# broken tool-call reconciliation downstream.
|
||||
|
||||
# Tool-call metadata must appear only on the appropriate role.
|
||||
if self.tool_calls is not None and self.role != "assistant":
|
||||
raise ValueError('"tool_calls" is only valid on role="assistant" messages.')
|
||||
if self.tool_call_id is not None and self.role != "tool":
|
||||
raise ValueError('"tool_call_id" is only valid on role="tool" messages.')
|
||||
if self.name is not None and self.role != "tool":
|
||||
raise ValueError('"name" is only valid on role="tool" messages.')
|
||||
|
||||
# Per-role content requirements. OpenAI-compatible clients may send
|
||||
# ``content=""`` for image-only turns when the image travels in a
|
||||
# companion field such as Studio's ``image_base64`` extension, so treat
|
||||
# empty strings as present content for user/system messages.
|
||||
if self.role == "tool":
|
||||
if not self.tool_call_id:
|
||||
raise ValueError(
|
||||
'role="tool" messages require "tool_call_id" per the OpenAI spec.'
|
||||
)
|
||||
if not self.content:
|
||||
raise ValueError('role="tool" messages require non-empty "content".')
|
||||
elif self.role == "assistant":
|
||||
# Assistant messages may omit content when tool_calls is set.
|
||||
if not self.content and not self.tool_calls:
|
||||
raise ValueError(
|
||||
'role="assistant" messages require either "content" or "tool_calls".'
|
||||
)
|
||||
else: # "user" | "system"
|
||||
if self.content is None or self.content == []:
|
||||
raise ValueError(f'role="{self.role}" messages require "content".')
|
||||
return self
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
|
|
@ -298,18 +446,49 @@ class ChatCompletionRequest(BaseModel):
|
|||
Extensions (non-OpenAI fields) are marked with 'x-unsloth'.
|
||||
"""
|
||||
|
||||
# Accept unknown fields defensively so future OpenAI fields (seed,
|
||||
# response_format, logprobs, frequency_penalty, etc.) don't get
|
||||
# silently dropped by Pydantic before route code runs. Mirrors
|
||||
# AnthropicMessagesRequest and ResponsesRequest.
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
model: str = Field(
|
||||
"default",
|
||||
description = "Model identifier (informational; the active model is used)",
|
||||
)
|
||||
messages: list[ChatMessage] = Field(..., description = "Conversation messages")
|
||||
stream: bool = Field(True, description = "Whether to stream the response via SSE")
|
||||
stream: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"Whether to stream the response via SSE. Default matches OpenAI's "
|
||||
"spec (`false`); opt into streaming by sending `stream: true`."
|
||||
),
|
||||
)
|
||||
temperature: float = Field(0.6, ge = 0.0, le = 2.0)
|
||||
top_p: float = Field(0.95, ge = 0.0, le = 1.0)
|
||||
max_tokens: Optional[int] = Field(
|
||||
None, ge = 1, description = "Maximum tokens to generate (None = until EOS)"
|
||||
)
|
||||
presence_penalty: float = Field(0.0, ge = 0.0, le = 2.0, description = "Presence penalty")
|
||||
stop: Optional[Union[str, list[str]]] = Field(
|
||||
None,
|
||||
description = "OpenAI stop sequences: a single string or list of strings at which generation halts.",
|
||||
)
|
||||
tools: Optional[list[dict]] = Field(
|
||||
None,
|
||||
description = (
|
||||
"OpenAI function-tool definitions. When provided without `enable_tools=true`, "
|
||||
"Studio forwards the tools to the backend so the model returns structured "
|
||||
"tool_calls for the client to execute (standard OpenAI function calling)."
|
||||
),
|
||||
)
|
||||
tool_choice: Optional[Union[str, dict]] = Field(
|
||||
None,
|
||||
description = (
|
||||
"OpenAI tool choice: 'auto' | 'required' | 'none' | "
|
||||
"{'type': 'function', 'function': {'name': ...}}"
|
||||
),
|
||||
)
|
||||
|
||||
# ── Unsloth extensions (ignored by standard OpenAI clients) ──
|
||||
top_k: int = Field(20, ge = -1, le = 100, description = "[x-unsloth] Top-k sampling")
|
||||
|
|
@ -339,6 +518,14 @@ class ChatCompletionRequest(BaseModel):
|
|||
None,
|
||||
description = "[x-unsloth] Enable/disable thinking/reasoning mode for supported models",
|
||||
)
|
||||
reasoning_effort: Optional[Literal["low", "medium", "high"]] = Field(
|
||||
None,
|
||||
description = "[x-unsloth] Reasoning effort level ('low'|'medium'|'high') for Harmony-style reasoning models (e.g. gpt-oss). Overrides enable_thinking when the active model uses reasoning_effort style.",
|
||||
)
|
||||
preserve_thinking: Optional[bool] = Field(
|
||||
None,
|
||||
description = "[x-unsloth] When true, keep historical <think> blocks from past assistant turns in the prompt (Qwen3.6 templates). Independent of enable_thinking / reasoning_effort.",
|
||||
)
|
||||
enable_tools: Optional[bool] = Field(
|
||||
None,
|
||||
description = "[x-unsloth] Enable tool calling for supported models",
|
||||
|
|
@ -365,6 +552,10 @@ class ChatCompletionRequest(BaseModel):
|
|||
None,
|
||||
description = "[x-unsloth] Session/thread ID for scoping tool execution sandbox.",
|
||||
)
|
||||
cancel_id: Optional[str] = Field(
|
||||
None,
|
||||
description = "[x-unsloth] Per-request cancellation token. Frontend sends a fresh UUID per run so /inference/cancel matches one specific generation.",
|
||||
)
|
||||
|
||||
# ── External provider routing (x-unsloth extensions) ──────────
|
||||
provider_id: Optional[str] = Field(
|
||||
|
|
@ -454,3 +645,435 @@ class ChatCompletion(BaseModel):
|
|||
model: str = "default"
|
||||
choices: list[CompletionChoice]
|
||||
usage: CompletionUsage = Field(default_factory = CompletionUsage)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# OpenAI Responses API Models (/v1/responses)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
# ── Request models ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class ResponsesInputTextPart(BaseModel):
|
||||
"""Text content part in a Responses API message (type=input_text)."""
|
||||
|
||||
type: Literal["input_text"]
|
||||
text: str
|
||||
|
||||
|
||||
class ResponsesInputImagePart(BaseModel):
|
||||
"""Image content part in a Responses API message (type=input_image)."""
|
||||
|
||||
type: Literal["input_image"]
|
||||
image_url: str = Field(..., description = "data:image/png;base64,... or https://...")
|
||||
detail: Optional[Literal["auto", "low", "high"]] = "auto"
|
||||
|
||||
|
||||
class ResponsesOutputTextPart(BaseModel):
|
||||
"""Assistant ``output_text`` content part replayed on subsequent turns.
|
||||
|
||||
When a client (OpenAI Codex CLI, OpenAI Python SDK agents) loops on a
|
||||
stateless Responses endpoint, prior assistant messages are round-tripped
|
||||
as ``{"role":"assistant","content":[{"type":"output_text","text":...,
|
||||
"annotations":[],"logprobs":[]}]}``. We preserve the text and ignore
|
||||
the annotations/logprobs metadata when flattening into Chat Completions.
|
||||
"""
|
||||
|
||||
type: Literal["output_text"]
|
||||
text: str
|
||||
annotations: Optional[list] = None
|
||||
logprobs: Optional[list] = None
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
class ResponsesUnknownContentPart(BaseModel):
|
||||
"""Catch-all for content-part types we don't model explicitly.
|
||||
|
||||
Keeps validation green when a client sends newer part types (e.g.
|
||||
``input_audio``, ``input_file``) we haven't mapped; these are silently
|
||||
skipped during normalisation rather than rejected with a 422.
|
||||
"""
|
||||
|
||||
type: str
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
ResponsesContentPart = Union[
|
||||
ResponsesInputTextPart,
|
||||
ResponsesInputImagePart,
|
||||
ResponsesOutputTextPart,
|
||||
ResponsesUnknownContentPart,
|
||||
]
|
||||
|
||||
|
||||
class ResponsesInputMessage(BaseModel):
|
||||
"""A single message in the Responses API input array."""
|
||||
|
||||
type: Optional[Literal["message"]] = None
|
||||
role: Literal["system", "user", "assistant", "developer"]
|
||||
content: Union[str, list[ResponsesContentPart]]
|
||||
|
||||
# Codex (gpt-5.3-codex+) attaches a `phase` field ("commentary" |
|
||||
# "final_answer") to assistant messages and requires clients to preserve
|
||||
# it on subsequent turns. We accept and round-trip it; llama-server does
|
||||
# not care about it.
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
class ResponsesFunctionCallInputItem(BaseModel):
|
||||
"""A prior assistant function_call being replayed in a multi-turn Responses input.
|
||||
|
||||
The Responses API represents tool calls as top-level input items (not
|
||||
nested inside assistant messages), correlated across turns by ``call_id``.
|
||||
"""
|
||||
|
||||
type: Literal["function_call"]
|
||||
id: Optional[str] = Field(
|
||||
None, description = "Item id assigned by the server (e.g. fc_...)"
|
||||
)
|
||||
call_id: str = Field(
|
||||
...,
|
||||
description = "Correlation id matching a function_call_output on the next turn.",
|
||||
)
|
||||
name: str
|
||||
arguments: str = Field(
|
||||
..., description = "JSON string of the arguments the model produced."
|
||||
)
|
||||
status: Optional[Literal["in_progress", "completed", "incomplete"]] = None
|
||||
|
||||
|
||||
class ResponsesFunctionCallOutputInputItem(BaseModel):
|
||||
"""A tool result supplied by the client for a prior function_call.
|
||||
|
||||
Replaces Chat Completions' ``role="tool"`` message. Correlated to the
|
||||
originating call by ``call_id``.
|
||||
"""
|
||||
|
||||
type: Literal["function_call_output"]
|
||||
id: Optional[str] = None
|
||||
call_id: str
|
||||
output: Union[str, list] = Field(
|
||||
..., description = "String or content-array result of the tool call."
|
||||
)
|
||||
status: Optional[Literal["in_progress", "completed", "incomplete"]] = None
|
||||
|
||||
|
||||
class ResponsesUnknownInputItem(BaseModel):
|
||||
"""Catch-all for Responses input item types we don't model explicitly.
|
||||
|
||||
Covers ``reasoning`` items (replayed from prior o-series / gpt-5 turns)
|
||||
and any future item types the client may send. These items are dropped
|
||||
during normalisation — llama-server-backed GGUFs cannot consume them —
|
||||
but keeping them in the request-model union stops unrelated turns from
|
||||
failing validation with a 422.
|
||||
"""
|
||||
|
||||
type: str
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
def _responses_input_item_discriminator(v: Any) -> str:
|
||||
"""Route a Responses input item to the correct tagged variant.
|
||||
|
||||
Pydantic's default smart-union matching fails when one variant in the
|
||||
union is tagged with a strict ``Literal`` (``function_call`` /
|
||||
``function_call_output``) and the incoming dict uses a different
|
||||
``type`` — the other variants' validation errors are hidden and the
|
||||
outer ``Union[str, list[...]]`` reports a misleading "Input should be a
|
||||
valid string" error. An explicit discriminator makes the routing
|
||||
deterministic and lets us fall through to the catch-all.
|
||||
"""
|
||||
if isinstance(v, dict):
|
||||
t = v.get("type")
|
||||
r = v.get("role")
|
||||
else:
|
||||
t = getattr(v, "type", None)
|
||||
r = getattr(v, "role", None)
|
||||
if t == "function_call":
|
||||
return "function_call"
|
||||
if t == "function_call_output":
|
||||
return "function_call_output"
|
||||
if r is not None or t == "message":
|
||||
return "message"
|
||||
return "unknown"
|
||||
|
||||
|
||||
ResponsesInputItem = Annotated[
|
||||
Union[
|
||||
Annotated[ResponsesInputMessage, Tag("message")],
|
||||
Annotated[ResponsesFunctionCallInputItem, Tag("function_call")],
|
||||
Annotated[ResponsesFunctionCallOutputInputItem, Tag("function_call_output")],
|
||||
Annotated[ResponsesUnknownInputItem, Tag("unknown")],
|
||||
],
|
||||
Discriminator(_responses_input_item_discriminator),
|
||||
]
|
||||
|
||||
|
||||
class ResponsesFunctionTool(BaseModel):
|
||||
"""Flat function-tool definition used by the Responses API request.
|
||||
|
||||
Unlike Chat Completions (which nests ``{"name": ..., "parameters": ...}``
|
||||
inside a ``"function"`` key), the Responses API uses a flat shape with
|
||||
``type``, ``name``, ``description``, ``parameters``, and ``strict`` at the
|
||||
top level of each tool entry.
|
||||
"""
|
||||
|
||||
type: Literal["function"]
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
parameters: Optional[dict] = None
|
||||
strict: Optional[bool] = None
|
||||
|
||||
|
||||
class ResponsesRequest(BaseModel):
|
||||
"""OpenAI Responses API request."""
|
||||
|
||||
model: str = Field("default", description = "Model identifier")
|
||||
input: Union[str, list[ResponsesInputItem]] = Field(
|
||||
default = [],
|
||||
description = "Input text or list of messages / function_call / function_call_output items",
|
||||
)
|
||||
instructions: Optional[str] = Field(
|
||||
None, description = "System / developer instructions"
|
||||
)
|
||||
temperature: Optional[float] = Field(None, ge = 0.0, le = 2.0)
|
||||
top_p: Optional[float] = Field(None, ge = 0.0, le = 1.0)
|
||||
max_output_tokens: Optional[int] = Field(None, ge = 1)
|
||||
stream: bool = Field(False, description = "Whether to stream the response via SSE")
|
||||
|
||||
# OpenAI function-calling fields — forwarded to llama-server via the
|
||||
# Chat Completions pass-through (see routes/inference.py). Typed as a
|
||||
# plain list so built-in tool shapes (``web_search``, ``file_search``,
|
||||
# ``mcp``, ...) round-trip without validation errors — the translator
|
||||
# picks out only ``type=="function"`` entries for forwarding.
|
||||
tools: Optional[list[dict]] = Field(
|
||||
None,
|
||||
description = (
|
||||
"Responses-shape function tool definitions. Entries with "
|
||||
'`type="function"` are translated to the Chat Completions nested '
|
||||
"shape before being forwarded to llama-server; other tool types "
|
||||
"(built-in web_search, file_search, mcp, ...) are accepted for SDK "
|
||||
"compatibility but ignored on the llama-server passthrough."
|
||||
),
|
||||
)
|
||||
tool_choice: Optional[Any] = Field(
|
||||
None,
|
||||
description = (
|
||||
"'auto' | 'required' | 'none' | {'type': 'function', 'name': ...} — "
|
||||
"the Responses-shape forcing object is translated to the Chat "
|
||||
"Completions nested shape internally."
|
||||
),
|
||||
)
|
||||
parallel_tool_calls: Optional[bool] = None
|
||||
|
||||
previous_response_id: Optional[str] = None
|
||||
store: Optional[bool] = None
|
||||
metadata: Optional[dict] = None
|
||||
truncation: Optional[Any] = None
|
||||
user: Optional[str] = None
|
||||
text: Optional[Any] = None
|
||||
reasoning: Optional[Any] = None
|
||||
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
# ── Response models ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class ResponsesOutputTextContent(BaseModel):
|
||||
"""A text content block inside an output message."""
|
||||
|
||||
type: Literal["output_text"] = "output_text"
|
||||
text: str
|
||||
annotations: list = Field(default_factory = list)
|
||||
|
||||
|
||||
class ResponsesOutputMessage(BaseModel):
|
||||
"""An output message in the Responses API response."""
|
||||
|
||||
type: Literal["message"] = "message"
|
||||
id: str = Field(default_factory = lambda: f"msg_{uuid.uuid4().hex[:12]}")
|
||||
status: Literal["completed", "in_progress"] = "completed"
|
||||
role: Literal["assistant"] = "assistant"
|
||||
content: list[ResponsesOutputTextContent] = Field(default_factory = list)
|
||||
|
||||
|
||||
class ResponsesOutputFunctionCall(BaseModel):
|
||||
"""A function-call output item in the Responses API response.
|
||||
|
||||
Unlike Chat Completions (which nests tool calls inside the assistant
|
||||
message), the Responses API emits each tool call as its own top-level
|
||||
``output`` item so clients can correlate results via ``call_id`` on the
|
||||
next turn.
|
||||
"""
|
||||
|
||||
type: Literal["function_call"] = "function_call"
|
||||
id: str = Field(default_factory = lambda: f"fc_{uuid.uuid4().hex[:12]}")
|
||||
call_id: str
|
||||
name: str
|
||||
arguments: str = Field(
|
||||
..., description = "JSON string of the arguments the model produced."
|
||||
)
|
||||
status: Literal["completed", "in_progress", "incomplete"] = "completed"
|
||||
|
||||
|
||||
ResponsesOutputItem = Union[ResponsesOutputMessage, ResponsesOutputFunctionCall]
|
||||
|
||||
|
||||
class ResponsesUsage(BaseModel):
|
||||
"""Token usage for a Responses API response (input_tokens, not prompt_tokens)."""
|
||||
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
class ResponsesResponse(BaseModel):
|
||||
"""Top-level Responses API response object."""
|
||||
|
||||
id: str = Field(default_factory = lambda: f"resp_{uuid.uuid4().hex[:12]}")
|
||||
object: Literal["response"] = "response"
|
||||
created_at: int = Field(default_factory = lambda: int(time.time()))
|
||||
status: Literal["completed", "in_progress", "failed"] = "completed"
|
||||
model: str = "default"
|
||||
output: list[ResponsesOutputItem] = Field(default_factory = list)
|
||||
usage: ResponsesUsage = Field(default_factory = ResponsesUsage)
|
||||
error: Optional[Any] = None
|
||||
incomplete_details: Optional[Any] = None
|
||||
instructions: Optional[str] = None
|
||||
metadata: dict = Field(default_factory = dict)
|
||||
temperature: Optional[float] = None
|
||||
top_p: Optional[float] = None
|
||||
max_output_tokens: Optional[int] = None
|
||||
previous_response_id: Optional[str] = None
|
||||
text: Optional[Any] = None
|
||||
tool_choice: Optional[Any] = None
|
||||
tools: list = Field(default_factory = list)
|
||||
truncation: Optional[Any] = None
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Anthropic Messages API Models (/v1/messages)
|
||||
# =====================================================================
|
||||
|
||||
|
||||
# ── Request models ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class AnthropicTextBlock(BaseModel):
|
||||
type: Literal["text"]
|
||||
text: str
|
||||
|
||||
|
||||
class AnthropicImageSource(BaseModel):
|
||||
type: Literal["base64", "url"]
|
||||
media_type: Optional[str] = None
|
||||
data: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
|
||||
|
||||
class AnthropicImageBlock(BaseModel):
|
||||
type: Literal["image"]
|
||||
source: AnthropicImageSource
|
||||
|
||||
|
||||
class AnthropicToolUseBlock(BaseModel):
|
||||
type: Literal["tool_use"]
|
||||
id: str
|
||||
name: str
|
||||
input: dict
|
||||
|
||||
|
||||
class AnthropicToolResultBlock(BaseModel):
|
||||
type: Literal["tool_result"]
|
||||
tool_use_id: str
|
||||
content: Union[str, list] = ""
|
||||
|
||||
|
||||
AnthropicContentBlock = Union[
|
||||
AnthropicTextBlock,
|
||||
AnthropicImageBlock,
|
||||
AnthropicToolUseBlock,
|
||||
AnthropicToolResultBlock,
|
||||
]
|
||||
|
||||
|
||||
class AnthropicMessage(BaseModel):
|
||||
role: Literal["user", "assistant"]
|
||||
content: Union[str, list[AnthropicContentBlock]]
|
||||
|
||||
|
||||
class AnthropicTool(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
input_schema: dict
|
||||
|
||||
|
||||
class AnthropicMessagesRequest(BaseModel):
|
||||
model: str = "default"
|
||||
max_tokens: Optional[int] = None
|
||||
messages: list[AnthropicMessage]
|
||||
system: Optional[Union[str, list]] = None
|
||||
tools: Optional[list[AnthropicTool]] = None
|
||||
tool_choice: Optional[Any] = None
|
||||
stream: bool = False
|
||||
temperature: Optional[float] = None
|
||||
top_p: Optional[float] = None
|
||||
top_k: Optional[int] = None
|
||||
stop_sequences: Optional[list[str]] = None
|
||||
metadata: Optional[dict] = None
|
||||
# [x-unsloth] extensions — mirror the OpenAI endpoint convenience fields
|
||||
min_p: Optional[float] = Field(
|
||||
None, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold"
|
||||
)
|
||||
repetition_penalty: Optional[float] = Field(
|
||||
None, ge = 1.0, le = 2.0, description = "[x-unsloth] Repetition penalty"
|
||||
)
|
||||
presence_penalty: Optional[float] = Field(
|
||||
None, ge = 0.0, le = 2.0, description = "[x-unsloth] Presence penalty"
|
||||
)
|
||||
enable_tools: Optional[bool] = None
|
||||
enabled_tools: Optional[list[str]] = None
|
||||
session_id: Optional[str] = None
|
||||
cancel_id: Optional[str] = None
|
||||
model_config = {"extra": "allow"}
|
||||
|
||||
|
||||
# ── Response models ────────────────────────────────────────────
|
||||
|
||||
|
||||
class AnthropicUsage(BaseModel):
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
|
||||
|
||||
class AnthropicResponseTextBlock(BaseModel):
|
||||
type: Literal["text"] = "text"
|
||||
text: str
|
||||
|
||||
|
||||
class AnthropicResponseToolUseBlock(BaseModel):
|
||||
type: Literal["tool_use"] = "tool_use"
|
||||
id: str
|
||||
name: str
|
||||
input: dict
|
||||
|
||||
|
||||
AnthropicResponseBlock = Union[
|
||||
AnthropicResponseTextBlock, AnthropicResponseToolUseBlock
|
||||
]
|
||||
|
||||
|
||||
class AnthropicMessagesResponse(BaseModel):
|
||||
id: str = Field(default_factory = lambda: f"msg_{uuid.uuid4().hex[:24]}")
|
||||
type: Literal["message"] = "message"
|
||||
role: Literal["assistant"] = "assistant"
|
||||
content: list[AnthropicResponseBlock] = Field(default_factory = list)
|
||||
model: str = "default"
|
||||
stop_reason: Optional[str] = None
|
||||
stop_sequence: Optional[str] = None
|
||||
usage: AnthropicUsage = Field(default_factory = AnthropicUsage)
|
||||
|
|
|
|||
|
|
@ -213,3 +213,68 @@ class ScanFolderInfo(BaseModel):
|
|||
id: int = Field(..., description = "Database row ID")
|
||||
path: str = Field(..., description = "Normalized absolute path")
|
||||
created_at: str = Field(..., description = "ISO 8601 creation timestamp")
|
||||
|
||||
|
||||
class BrowseEntry(BaseModel):
|
||||
"""A directory entry surfaced by the folder browser."""
|
||||
|
||||
name: str = Field(..., description = "Entry name (basename, not full path)")
|
||||
has_models: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"Hint that the directory likely contains models "
|
||||
"(*.gguf, *.safetensors, config.json, or HF-style "
|
||||
"`models--*` subfolders). Used by the UI to highlight "
|
||||
"promising candidates; the scanner itself is authoritative."
|
||||
),
|
||||
)
|
||||
hidden: bool = Field(
|
||||
False,
|
||||
description = "Name starts with a dot (e.g. `.cache`)",
|
||||
)
|
||||
|
||||
|
||||
class BrowseFoldersResponse(BaseModel):
|
||||
"""Response schema for the folder browser endpoint."""
|
||||
|
||||
current: str = Field(..., description = "Absolute path of the directory just listed")
|
||||
parent: Optional[str] = Field(
|
||||
None,
|
||||
description = (
|
||||
"Parent directory of `current`, or null if `current` is the "
|
||||
"filesystem root. The frontend uses this to render an `Up` row."
|
||||
),
|
||||
)
|
||||
entries: List[BrowseEntry] = Field(
|
||||
default_factory = list,
|
||||
description = (
|
||||
"Subdirectories of `current`. Sorted with model-bearing "
|
||||
"directories first, then alphabetically case-insensitive; "
|
||||
"hidden entries come last within each group."
|
||||
),
|
||||
)
|
||||
suggestions: List[str] = Field(
|
||||
default_factory = list,
|
||||
description = (
|
||||
"Handy starting points (home, HF cache, already-registered "
|
||||
"scan folders). Rendered as quick-pick chips above the list."
|
||||
),
|
||||
)
|
||||
truncated: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"True when the listing was capped because the directory had "
|
||||
"more subfolders than the server is willing to enumerate in "
|
||||
"one request. The UI should show a hint telling the user to "
|
||||
"narrow their path."
|
||||
),
|
||||
)
|
||||
model_files_here: int = Field(
|
||||
0,
|
||||
description = (
|
||||
"Count of GGUF/safetensors files immediately inside "
|
||||
"``current``. Used by the UI to surface a hint on leaf "
|
||||
"model directories (which otherwise look `empty` because "
|
||||
"they contain only files, no subdirectories)."
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ class TrainingStartRequest(BaseModel):
|
|||
wandb_project: Optional[str] = Field(None, description = "W&B project name")
|
||||
enable_tensorboard: bool = Field(False, description = "Enable TensorBoard logging")
|
||||
tensorboard_dir: Optional[str] = Field(None, description = "TensorBoard directory")
|
||||
resume_from_checkpoint: Optional[str] = Field(
|
||||
None, description = "Saved training output directory to resume from"
|
||||
)
|
||||
|
||||
# GPU selection
|
||||
gpu_ids: Optional[List[int]] = Field(
|
||||
|
|
@ -220,6 +223,8 @@ class TrainingRunSummary(BaseModel):
|
|||
duration_seconds: Optional[float] = None
|
||||
error_message: Optional[str] = None
|
||||
loss_sparkline: Optional[List[float]] = None
|
||||
can_resume: bool = False
|
||||
resumed_later: bool = False
|
||||
|
||||
|
||||
class TrainingRunListResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
# data-designer-github-repo-seed
|
||||
|
||||
A Data Designer seed-reader plugin for **Unsloth Studio** that scrapes real
|
||||
GitHub data (issues, pull requests, commits) from one or more repositories
|
||||
and hands it to the recipe pipeline as a seed dataset.
|
||||
|
||||
Designed to ship with Studio as a default seed source so any user with a
|
||||
GitHub token can build training datasets straight from live repos.
|
||||
|
||||
## What it does
|
||||
|
||||
Given a list of `owner/name` repos, a GitHub token, and a per-resource
|
||||
`limit`, the plugin uses GitHub's GraphQL API to fetch issues, pull
|
||||
requests, and/or commits, with labels, state, authors, and the first N
|
||||
comments of each item, and materialises a single JSONL with uniform
|
||||
columns so the rest of the recipe (LLM text / LLM structured / processors)
|
||||
can treat it like any other seed table.
|
||||
|
||||
| Column | Description |
|
||||
|---------------|------------------------------------------------|
|
||||
| `item_type` | `issue` / `pull` / `commit` |
|
||||
| `repo` | `owner/name` |
|
||||
| `number` | Issue/PR number, or commit SHA |
|
||||
| `title` | Title (or commit message headline) |
|
||||
| `body` | Issue/PR body (or full commit message) |
|
||||
| `state` | `OPEN` / `CLOSED` / `MERGED` (empty for commit)|
|
||||
| `author` | GitHub login of the author |
|
||||
| `created_at` | ISO8601 |
|
||||
| `closed_at` | ISO8601 (empty for commits) |
|
||||
| `url` | Permalink |
|
||||
| `labels` | List of label names |
|
||||
| `comments` | First N comments concatenated |
|
||||
|
||||
## Usage in a recipe
|
||||
|
||||
```json
|
||||
{
|
||||
"seed_config": {
|
||||
"source": {
|
||||
"seed_type": "github_repo",
|
||||
"repos": ["unslothai/unsloth", "unslothai/unsloth-zoo"],
|
||||
"token": "",
|
||||
"item_types": ["issues", "pulls"],
|
||||
"limit": 100,
|
||||
"include_comments": true,
|
||||
"max_comments_per_item": 30
|
||||
},
|
||||
"sampling_strategy": "shuffle",
|
||||
"selection_strategy": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Leave `token` empty to fall back to the server's `GH_TOKEN` / `GITHUB_TOKEN`
|
||||
environment variable, useful when the recipe is published and shouldn't
|
||||
carry a secret.
|
||||
|
||||
## Auth
|
||||
|
||||
A GitHub personal access token with `public_repo` scope is enough for public
|
||||
repositories; `repo` scope is required for private ones. GraphQL requests
|
||||
are rate-limit aware: the client inspects `x-ratelimit-*` headers and
|
||||
sleeps until reset when the budget drops below a safety threshold.
|
||||
|
||||
## Install
|
||||
|
||||
Shipped as a default Studio plugin. For development:
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
Registered automatically via the `data_designer.plugins` entry point.
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "data-designer-github-repo-seed"
|
||||
version = "0.1.0"
|
||||
description = "Unsloth Studio seed plugin that scrapes GitHub issues, PRs, and commits."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"data-designer-engine>=0.5.4,<0.6",
|
||||
"requests>=2.31",
|
||||
]
|
||||
|
||||
[project.entry-points."data_designer.plugins"]
|
||||
github_repo_seed = "data_designer_github_repo_seed.plugin:github_repo_seed_plugin"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
# Intentionally empty. Data-designer loads submodules lazily via qualified names
|
||||
# (impl_qualified_name / config_qualified_name in plugin.py), so importing this
|
||||
# package must NOT touch modules that depend on data_designer.engine.* during
|
||||
# Studio's bootstrap (circular import).
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# 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 __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
|
||||
from data_designer.config.seed_source import SeedSource
|
||||
|
||||
|
||||
class GitHubRepoSeedSource(SeedSource):
|
||||
seed_type: Literal["github_repo"] = "github_repo"
|
||||
|
||||
repos: list[str] = Field(
|
||||
default_factory = list,
|
||||
description = "List of GitHub repositories to scrape, each in `owner/name` form.",
|
||||
)
|
||||
token: str = Field(
|
||||
default = "",
|
||||
description = "Personal access token. Leave blank to read GH_TOKEN / GITHUB_TOKEN from env at run time.",
|
||||
)
|
||||
item_types: list[Literal["issues", "pulls", "commits"]] = Field(
|
||||
default = ["issues", "pulls"],
|
||||
description = "Which GitHub item types to fetch per repo.",
|
||||
)
|
||||
limit: int = Field(
|
||||
default = 100,
|
||||
ge = 1,
|
||||
le = 5000,
|
||||
description = "Maximum items per repo per item type (e.g. limit=100 + ['issues','pulls'] => up to 200 items per repo).",
|
||||
)
|
||||
include_comments: bool = Field(
|
||||
default = True,
|
||||
description = "Fetch the first N comments of each issue/PR and include them in the `comments` column.",
|
||||
)
|
||||
max_comments_per_item: int = Field(default = 30, ge = 0, le = 200)
|
||||
|
||||
@field_validator("repos")
|
||||
@classmethod
|
||||
def _validate_repos(cls, v: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for r in v or []:
|
||||
r = r.strip()
|
||||
if not r:
|
||||
continue
|
||||
if r.count("/") != 1 or not all(r.split("/")):
|
||||
raise ValueError(f"Each repo must be `owner/name`; got {r!r}")
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
@field_validator("item_types")
|
||||
@classmethod
|
||||
def _validate_item_types(cls, v: list[str]) -> list[str]:
|
||||
if not v:
|
||||
raise ValueError("item_types must not be empty")
|
||||
return list(dict.fromkeys(v))
|
||||
|
||||
@model_validator(mode = "after")
|
||||
def _ensure_repos(self) -> "GitHubRepoSeedSource":
|
||||
if not self.repos:
|
||||
raise ValueError("At least one repo is required")
|
||||
return self
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# 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 __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import data_designer.lazy_heavy_imports as lazy
|
||||
from data_designer.engine.resources.seed_reader import SeedReader
|
||||
|
||||
from .config import GitHubRepoSeedSource
|
||||
from .scraper import ScrapeConfig, materialize_to_jsonl
|
||||
|
||||
|
||||
# In-process cache mapping a stable config signature to the JSONL materialization
|
||||
# path. A single recipe job invokes the seed reader multiple times (validation,
|
||||
# preview, per-column sampling), and the default flow re-scrapes the repo on
|
||||
# every call: for a 2-repo preview that is ~15s of redundant GitHub GraphQL
|
||||
# traffic before any generation fires. Memoize the materialization so the second
|
||||
# and third passes reuse the file the first pass wrote. Cache key excludes the
|
||||
# raw token and uses a short SHA-256 digest so token values never hit memory
|
||||
# twice and token rotation invalidates cleanly.
|
||||
_SCRAPE_CACHE: dict[tuple, str] = {}
|
||||
_SCRAPE_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _scrape_cache_key(cfg: ScrapeConfig) -> tuple:
|
||||
token_digest = hashlib.sha256(
|
||||
(cfg.token or "").encode("utf-8"),
|
||||
).hexdigest()[:16]
|
||||
return (
|
||||
tuple(cfg.repos),
|
||||
tuple(cfg.item_types),
|
||||
cfg.limit,
|
||||
bool(cfg.include_comments),
|
||||
cfg.max_comments_per_item,
|
||||
token_digest,
|
||||
)
|
||||
|
||||
|
||||
def _lookup_cached_scrape(key: tuple) -> Optional[str]:
|
||||
with _SCRAPE_CACHE_LOCK:
|
||||
path = _SCRAPE_CACHE.get(key)
|
||||
if path and Path(path).exists():
|
||||
return path
|
||||
# Stale entry (tmp cleanup, user restarted, ...); drop it so the caller
|
||||
# materializes a fresh file rather than returning a dangling path.
|
||||
if path:
|
||||
with _SCRAPE_CACHE_LOCK:
|
||||
_SCRAPE_CACHE.pop(key, None)
|
||||
return None
|
||||
|
||||
|
||||
def _store_cached_scrape(key: tuple, path: str) -> None:
|
||||
with _SCRAPE_CACHE_LOCK:
|
||||
_SCRAPE_CACHE[key] = path
|
||||
|
||||
|
||||
class GitHubRepoSeedReader(SeedReader[GitHubRepoSeedSource]):
|
||||
def create_duckdb_connection(self):
|
||||
return lazy.duckdb.connect()
|
||||
|
||||
def get_dataset_uri(self) -> str:
|
||||
out_dir = Path(tempfile.gettempdir()) / "studio-github-repo-seed"
|
||||
cfg = ScrapeConfig(
|
||||
repos = list(self.source.repos),
|
||||
token = self.source.token,
|
||||
item_types = list(self.source.item_types),
|
||||
limit = self.source.limit,
|
||||
include_comments = self.source.include_comments,
|
||||
max_comments_per_item = self.source.max_comments_per_item,
|
||||
)
|
||||
cache_key = _scrape_cache_key(cfg)
|
||||
cached_path = _lookup_cached_scrape(cache_key)
|
||||
if cached_path is not None:
|
||||
return cached_path
|
||||
path = materialize_to_jsonl(cfg, out_dir)
|
||||
_store_cached_scrape(cache_key, str(path))
|
||||
return str(path)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
# 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 data_designer.plugins.plugin import Plugin, PluginType
|
||||
|
||||
github_repo_seed_plugin = Plugin(
|
||||
impl_qualified_name = "data_designer_github_repo_seed.impl.GitHubRepoSeedReader",
|
||||
config_qualified_name = "data_designer_github_repo_seed.config.GitHubRepoSeedSource",
|
||||
plugin_type = PluginType.SEED_READER,
|
||||
)
|
||||
|
|
@ -0,0 +1,236 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Multi-repo GitHub scraper for the Studio seed plugin.
|
||||
|
||||
Drives the GraphQL-based scraper in `scraper_impl/` per repo. Each repo is
|
||||
scraped with a trial_limits cap so we stop at `limit` items per resource.
|
||||
After scraping, we read the per-resource JSONL shards and flatten them into
|
||||
a single unified JSONL with stable columns (`item_type`, `repo`, `number`,
|
||||
`title`, `body`, ...).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Defer scraper_impl imports until `scrape()` runs with a resolved token.
|
||||
_IMPL_DIR = Path(__file__).parent / "scraper_impl"
|
||||
|
||||
|
||||
def _ensure_impl_on_path() -> None:
|
||||
if str(_IMPL_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_IMPL_DIR))
|
||||
|
||||
|
||||
def _load_impl():
|
||||
_ensure_impl_on_path()
|
||||
import importlib
|
||||
|
||||
gh_client = importlib.import_module("gh_client") # type: ignore
|
||||
scraper_mod = importlib.import_module("scraper") # type: ignore
|
||||
return gh_client.GitHubClient, scraper_mod.RepoScraper
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScrapeConfig:
|
||||
repos: list[str]
|
||||
token: str
|
||||
item_types: list[str]
|
||||
limit: int
|
||||
include_comments: bool
|
||||
max_comments_per_item: int
|
||||
|
||||
|
||||
def _resolve_token(token: str) -> str:
|
||||
tok = token or os.environ.get("GH_TOKEN", "") or os.environ.get("GITHUB_TOKEN", "")
|
||||
if not tok:
|
||||
raise ValueError(
|
||||
"GitHub token is required. Set it in the recipe config or the GH_TOKEN / GITHUB_TOKEN env var."
|
||||
)
|
||||
return tok
|
||||
|
||||
|
||||
def _read_jsonl(path: Path, max_rows: int | None = None):
|
||||
if not path.exists():
|
||||
return
|
||||
with path.open(encoding = "utf-8") as f:
|
||||
for i, line in enumerate(f):
|
||||
if not line.strip():
|
||||
continue
|
||||
if max_rows is not None and i >= max_rows:
|
||||
return
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
def _flatten_issue_row(r: dict, repo: str, include_comments: bool, max_c: int) -> dict:
|
||||
labels = [
|
||||
l.get("name")
|
||||
for l in (r.get("labels", {}) or {}).get("nodes", [])
|
||||
if l.get("name")
|
||||
]
|
||||
comments_nodes = (r.get("comments") or {}).get("nodes") or []
|
||||
comments_text = ""
|
||||
if include_comments and comments_nodes:
|
||||
kept = comments_nodes[:max_c]
|
||||
comments_text = "\n\n".join(
|
||||
f"[{(c.get('author') or {}).get('login', '?')}]: {c.get('body') or ''}"
|
||||
for c in kept
|
||||
)
|
||||
return {
|
||||
"item_type": "issue",
|
||||
"repo": repo,
|
||||
"number": r.get("number"),
|
||||
"title": r.get("title") or "",
|
||||
"body": r.get("body") or "",
|
||||
"state": r.get("state") or "",
|
||||
"author": (r.get("author") or {}).get("login", ""),
|
||||
"created_at": r.get("createdAt") or "",
|
||||
"closed_at": r.get("closedAt") or "",
|
||||
"url": r.get("url") or r.get("permalink") or "",
|
||||
"labels": labels,
|
||||
"comments": comments_text,
|
||||
}
|
||||
|
||||
|
||||
def _flatten_pr_row(r: dict, repo: str, include_comments: bool, max_c: int) -> dict:
|
||||
labels = [
|
||||
l.get("name")
|
||||
for l in (r.get("labels", {}) or {}).get("nodes", [])
|
||||
if l.get("name")
|
||||
]
|
||||
comments_nodes = (r.get("comments") or {}).get("nodes") or []
|
||||
comments_text = ""
|
||||
if include_comments and comments_nodes:
|
||||
kept = comments_nodes[:max_c]
|
||||
comments_text = "\n\n".join(
|
||||
f"[{(c.get('author') or {}).get('login', '?')}]: {c.get('body') or ''}"
|
||||
for c in kept
|
||||
)
|
||||
return {
|
||||
"item_type": "pull",
|
||||
"repo": repo,
|
||||
"number": r.get("number"),
|
||||
"title": r.get("title") or "",
|
||||
"body": r.get("body") or "",
|
||||
"state": r.get("state") or "",
|
||||
"author": (r.get("author") or {}).get("login", ""),
|
||||
"created_at": r.get("createdAt") or "",
|
||||
"closed_at": r.get("closedAt") or "",
|
||||
"url": r.get("url") or r.get("permalink") or "",
|
||||
"labels": labels,
|
||||
"comments": comments_text,
|
||||
}
|
||||
|
||||
|
||||
def _flatten_commit_row(r: dict, repo: str) -> dict:
|
||||
msg = r.get("messageHeadline") or r.get("message") or ""
|
||||
body = r.get("messageBody") or r.get("message") or msg
|
||||
author = r.get("author") or {}
|
||||
return {
|
||||
"item_type": "commit",
|
||||
"repo": repo,
|
||||
"number": r.get("oid") or r.get("sha") or "",
|
||||
"title": msg,
|
||||
"body": body,
|
||||
"state": "",
|
||||
"author": (author.get("user") or {}).get("login") or author.get("name", ""),
|
||||
"created_at": (author.get("date") or r.get("committedDate") or ""),
|
||||
"closed_at": "",
|
||||
"url": r.get("url") or "",
|
||||
"labels": [],
|
||||
"comments": "",
|
||||
}
|
||||
|
||||
|
||||
def scrape(cfg: ScrapeConfig, base_dir: Path):
|
||||
token = _resolve_token(cfg.token)
|
||||
GitHubClient, RepoScraper = _load_impl()
|
||||
client = GitHubClient(token = token)
|
||||
base_dir.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
# Per-resource trial limits. limit <= 0 means "all": use a very large cap.
|
||||
effective_limit = cfg.limit if cfg.limit and cfg.limit > 0 else 1_000_000
|
||||
trial_limits: dict[str, int] = {}
|
||||
if "issues" in cfg.item_types:
|
||||
trial_limits["issues"] = effective_limit
|
||||
if "pulls" in cfg.item_types:
|
||||
trial_limits["pull_requests"] = effective_limit
|
||||
if "commits" in cfg.item_types:
|
||||
trial_limits["commits"] = effective_limit
|
||||
|
||||
all_rows: list[dict] = []
|
||||
for repo in cfg.repos:
|
||||
owner, name = repo.split("/", 1)
|
||||
scraper = RepoScraper(
|
||||
owner = owner,
|
||||
name = name,
|
||||
base_dir = base_dir,
|
||||
client = client,
|
||||
trial_limits = trial_limits,
|
||||
light = True,
|
||||
)
|
||||
try:
|
||||
repo_meta = scraper.scrape_repo_meta()
|
||||
if "issues" in cfg.item_types:
|
||||
scraper.scrape_issues()
|
||||
if "pulls" in cfg.item_types:
|
||||
scraper.scrape_prs()
|
||||
if "commits" in cfg.item_types:
|
||||
default_ref = repo_meta.get("defaultBranchRef") or {}
|
||||
default_branch = (
|
||||
default_ref.get("name") if isinstance(default_ref, dict) else None
|
||||
)
|
||||
branch = (
|
||||
f"refs/heads/{default_branch}"
|
||||
if default_branch
|
||||
else "refs/heads/main"
|
||||
)
|
||||
scraper.scrape_commits(branch = branch)
|
||||
finally:
|
||||
scraper.close()
|
||||
|
||||
read_cap = cfg.limit if cfg.limit and cfg.limit > 0 else None
|
||||
repo_dir = base_dir / f"{owner}__{name}"
|
||||
if "issues" in cfg.item_types:
|
||||
for row in _read_jsonl(repo_dir / "issues.jsonl", read_cap):
|
||||
all_rows.append(
|
||||
_flatten_issue_row(
|
||||
row, repo, cfg.include_comments, cfg.max_comments_per_item
|
||||
)
|
||||
)
|
||||
if "pulls" in cfg.item_types:
|
||||
for row in _read_jsonl(repo_dir / "pull_requests.jsonl", read_cap):
|
||||
all_rows.append(
|
||||
_flatten_pr_row(
|
||||
row, repo, cfg.include_comments, cfg.max_comments_per_item
|
||||
)
|
||||
)
|
||||
if "commits" in cfg.item_types:
|
||||
for row in _read_jsonl(repo_dir / "commits.jsonl", read_cap):
|
||||
all_rows.append(_flatten_commit_row(row, repo))
|
||||
|
||||
return all_rows
|
||||
|
||||
|
||||
def materialize_to_jsonl(cfg: ScrapeConfig, out_dir: Path) -> Path:
|
||||
out_dir.mkdir(parents = True, exist_ok = True)
|
||||
tag = "-".join(r.replace("/", "__") for r in cfg.repos)[:120]
|
||||
kinds = "-".join(cfg.item_types)
|
||||
run_id = f"{int(time.time())}-{uuid.uuid4().hex[:12]}"
|
||||
fname = f"github_{tag}__{kinds}__{cfg.limit}_{run_id}.jsonl"
|
||||
out = out_dir / fname
|
||||
rows = scrape(cfg, out_dir / "raw-runs" / run_id)
|
||||
with out.open("w", encoding = "utf-8") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii = False) + "\n")
|
||||
return out
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""GitHub API client with rate-limit awareness, retry, and dual REST/GraphQL support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
log = logging.getLogger("gh_client")
|
||||
|
||||
GRAPHQL_URL = "https://api.github.com/graphql"
|
||||
REST_BASE = "https://api.github.com"
|
||||
|
||||
BASE_HEADERS = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "github-data-gatherer/1.0",
|
||||
}
|
||||
|
||||
|
||||
class RateLimitError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
def __init__(
|
||||
self,
|
||||
min_remaining_graphql: int = 100,
|
||||
min_remaining_rest: int = 100,
|
||||
token: str | None = None,
|
||||
):
|
||||
token = token or os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
||||
if not token:
|
||||
raise RuntimeError("GH_TOKEN not set in environment")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{**BASE_HEADERS, "Authorization": f"Bearer {token}"}
|
||||
)
|
||||
self.min_remaining_graphql = min_remaining_graphql
|
||||
self.min_remaining_rest = min_remaining_rest
|
||||
self.graphql_remaining: Optional[int] = None
|
||||
self.graphql_reset: Optional[int] = None
|
||||
self.rest_remaining: Optional[int] = None
|
||||
self.rest_reset: Optional[int] = None
|
||||
self.calls_graphql = 0
|
||||
self.calls_rest = 0
|
||||
self.retry_count = 0
|
||||
|
||||
def _sleep_until(self, reset_ts: int, buffer_s: int = 10) -> None:
|
||||
now = int(time.time())
|
||||
wait = max(0, reset_ts - now) + buffer_s
|
||||
log.warning("Rate limit hit. Sleeping %ds until reset.", wait)
|
||||
time.sleep(wait)
|
||||
|
||||
def _check_rate_and_wait(self, kind: str) -> None:
|
||||
if kind == "graphql":
|
||||
remaining = self.graphql_remaining
|
||||
reset = self.graphql_reset
|
||||
min_remaining = self.min_remaining_graphql
|
||||
else:
|
||||
remaining = self.rest_remaining
|
||||
reset = self.rest_reset
|
||||
min_remaining = self.min_remaining_rest
|
||||
if remaining is not None and remaining < min_remaining:
|
||||
if reset:
|
||||
self._sleep_until(reset)
|
||||
# Reset remaining so we don't spin
|
||||
if kind == "graphql":
|
||||
self.graphql_remaining = None
|
||||
else:
|
||||
self.rest_remaining = None
|
||||
|
||||
def graphql(
|
||||
self,
|
||||
query: str,
|
||||
variables: Optional[Dict[str, Any]] = None,
|
||||
max_retries: int = 20,
|
||||
) -> Dict[str, Any]:
|
||||
self._check_rate_and_wait("graphql")
|
||||
backoff = 2
|
||||
last_err = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
r = self.session.post(
|
||||
GRAPHQL_URL,
|
||||
json = {"query": query, "variables": variables or {}},
|
||||
timeout = 120,
|
||||
)
|
||||
self.calls_graphql += 1
|
||||
# Update rate info from response headers
|
||||
rem = r.headers.get("X-RateLimit-Remaining")
|
||||
rst = r.headers.get("X-RateLimit-Reset")
|
||||
if rem is not None:
|
||||
try:
|
||||
self.graphql_remaining = int(rem)
|
||||
except ValueError:
|
||||
pass
|
||||
if rst is not None:
|
||||
try:
|
||||
self.graphql_reset = int(rst)
|
||||
except ValueError:
|
||||
pass
|
||||
if r.status_code in (502, 503, 504):
|
||||
log.warning("GraphQL %s transient, retrying", r.status_code)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
continue
|
||||
if r.status_code == 403 or r.status_code == 429:
|
||||
# Check for secondary/abuse
|
||||
retry_after = r.headers.get("Retry-After")
|
||||
if retry_after:
|
||||
t = int(retry_after)
|
||||
log.warning("Secondary rate limit. Sleep %ds.", t)
|
||||
time.sleep(t + 2)
|
||||
continue
|
||||
if self.graphql_reset:
|
||||
self._sleep_until(self.graphql_reset)
|
||||
continue
|
||||
time.sleep(60)
|
||||
continue
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if "errors" in data and data["errors"]:
|
||||
# Surface errors but allow partial data
|
||||
errs = data["errors"]
|
||||
# Retry on RATE_LIMITED
|
||||
for e in errs:
|
||||
if e.get("type") == "RATE_LIMITED":
|
||||
self._sleep_until(
|
||||
(self.graphql_reset or int(time.time()) + 60)
|
||||
)
|
||||
break
|
||||
else:
|
||||
# No rate-limit error, log and return partial
|
||||
log.warning("GraphQL errors: %s", json.dumps(errs)[:400])
|
||||
return data
|
||||
continue
|
||||
return data
|
||||
except requests.RequestException as e:
|
||||
last_err = e
|
||||
log.warning("GraphQL network error: %s. Retry.", e)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
raise RuntimeError(f"GraphQL failed after {max_retries} retries: {last_err}")
|
||||
|
||||
def rest(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
max_retries: int = 6,
|
||||
) -> requests.Response:
|
||||
self._check_rate_and_wait("rest")
|
||||
if path.startswith("http"):
|
||||
url = path
|
||||
else:
|
||||
url = REST_BASE + path
|
||||
backoff = 2
|
||||
last_err = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
r = self.session.request(
|
||||
method, url, params = params, json = json_body, timeout = 120
|
||||
)
|
||||
self.calls_rest += 1
|
||||
rem = r.headers.get("X-RateLimit-Remaining")
|
||||
rst = r.headers.get("X-RateLimit-Reset")
|
||||
if rem is not None:
|
||||
try:
|
||||
self.rest_remaining = int(rem)
|
||||
except ValueError:
|
||||
pass
|
||||
if rst is not None:
|
||||
try:
|
||||
self.rest_reset = int(rst)
|
||||
except ValueError:
|
||||
pass
|
||||
if r.status_code in (502, 503, 504):
|
||||
log.warning("REST %s transient, retrying", r.status_code)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
continue
|
||||
if r.status_code in (403, 429):
|
||||
retry_after = r.headers.get("Retry-After")
|
||||
if retry_after:
|
||||
t = int(retry_after)
|
||||
log.warning("Secondary rate limit on REST. Sleep %ds.", t)
|
||||
time.sleep(t + 2)
|
||||
continue
|
||||
# Check if primary rate
|
||||
if self.rest_remaining == 0 and self.rest_reset:
|
||||
self._sleep_until(self.rest_reset)
|
||||
continue
|
||||
log.warning("REST 403/429, sleep 60")
|
||||
time.sleep(60)
|
||||
continue
|
||||
return r
|
||||
except requests.RequestException as e:
|
||||
last_err = e
|
||||
log.warning("REST network error: %s. Retry.", e)
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60)
|
||||
raise RuntimeError(f"REST failed after {max_retries} retries: {last_err}")
|
||||
|
||||
def rest_paginate(
|
||||
self, path: str, params: Optional[Dict[str, Any]] = None, per_page: int = 100
|
||||
) -> Iterator[dict]:
|
||||
params = dict(params or {})
|
||||
params.setdefault("per_page", per_page)
|
||||
url = path
|
||||
while True:
|
||||
r = self.rest("GET", url, params = params if url == path else None)
|
||||
if r.status_code != 200:
|
||||
log.error(
|
||||
"REST paginate got %s at %s: %s", r.status_code, url, r.text[:200]
|
||||
)
|
||||
return
|
||||
items = r.json()
|
||||
if isinstance(items, dict):
|
||||
# Some endpoints return dict with list field
|
||||
items = items.get("items", [])
|
||||
for it in items:
|
||||
yield it
|
||||
# Follow link header
|
||||
link = r.headers.get("Link", "")
|
||||
nxt = None
|
||||
for part in link.split(","):
|
||||
if 'rel="next"' in part:
|
||||
nxt = part.split(";")[0].strip().strip("<>")
|
||||
break
|
||||
if not nxt:
|
||||
return
|
||||
url = nxt
|
||||
params = None
|
||||
|
||||
def rate_snapshot(self) -> Dict[str, Any]:
|
||||
r = self.rest("GET", "/rate_limit")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
return {}
|
||||
|
|
@ -0,0 +1,685 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""GraphQL queries for GitHub data scraping.
|
||||
|
||||
GitHub's GraphQL rejects queries that define unused fragments, so each query
|
||||
only includes the fragments it actually references.
|
||||
"""
|
||||
|
||||
# ---- Fragments (kept as raw strings, composed per query) ----
|
||||
F_ACTOR = """
|
||||
fragment ActorFields on Actor {
|
||||
__typename
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
... on User { id databaseId name }
|
||||
... on Bot { id databaseId }
|
||||
... on Organization { id databaseId name }
|
||||
}
|
||||
"""
|
||||
|
||||
F_LABEL = """
|
||||
fragment LabelFields on Label {
|
||||
id
|
||||
name
|
||||
color
|
||||
description
|
||||
createdAt
|
||||
}
|
||||
"""
|
||||
|
||||
F_TIMELINE = """
|
||||
fragment TimelineItem on IssueTimelineItems {
|
||||
__typename
|
||||
... on Node { id }
|
||||
... on AddedToProjectEvent { createdAt actor { ...ActorFields } }
|
||||
... on AssignedEvent { createdAt actor { ...ActorFields } assignee { __typename ... on User { login } ... on Bot { login } } }
|
||||
... on ClosedEvent { createdAt actor { ...ActorFields } stateReason closer { __typename ... on Commit { oid url } ... on PullRequest { number url } } }
|
||||
... on CommentDeletedEvent { createdAt actor { ...ActorFields } }
|
||||
... on ConnectedEvent { createdAt actor { ...ActorFields } source { __typename ... on Issue { number url repository { nameWithOwner } } ... on PullRequest { number url repository { nameWithOwner } } } subject { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on ConvertedNoteToIssueEvent { createdAt actor { ...ActorFields } }
|
||||
... on CrossReferencedEvent { createdAt actor { ...ActorFields } isCrossRepository willCloseTarget source { __typename ... on Issue { number url repository { nameWithOwner } title } ... on PullRequest { number url repository { nameWithOwner } title } } }
|
||||
... on DemilestonedEvent { createdAt actor { ...ActorFields } milestoneTitle }
|
||||
... on DisconnectedEvent { createdAt actor { ...ActorFields } subject { __typename ... on Issue { number url } ... on PullRequest { number url } } source { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on IssueComment { id databaseId createdAt updatedAt author { ...ActorFields } body url reactionGroups { content reactors { totalCount } } }
|
||||
... on LabeledEvent { createdAt actor { ...ActorFields } label { name color } }
|
||||
... on LockedEvent { createdAt actor { ...ActorFields } lockReason }
|
||||
... on MarkedAsDuplicateEvent { createdAt actor { ...ActorFields } canonical { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on MentionedEvent { createdAt actor { ...ActorFields } }
|
||||
... on MilestonedEvent { createdAt actor { ...ActorFields } milestoneTitle }
|
||||
... on MovedColumnsInProjectEvent { createdAt actor { ...ActorFields } }
|
||||
... on PinnedEvent { createdAt actor { ...ActorFields } }
|
||||
... on ReferencedEvent { createdAt actor { ...ActorFields } commit { oid url } commitRepository { nameWithOwner } }
|
||||
... on RemovedFromProjectEvent { createdAt actor { ...ActorFields } }
|
||||
... on RenamedTitleEvent { createdAt actor { ...ActorFields } previousTitle currentTitle }
|
||||
... on ReopenedEvent { createdAt actor { ...ActorFields } }
|
||||
... on SubscribedEvent { createdAt actor { ...ActorFields } }
|
||||
... on TransferredEvent { createdAt actor { ...ActorFields } fromRepository { nameWithOwner } }
|
||||
... on UnassignedEvent { createdAt actor { ...ActorFields } assignee { __typename ... on User { login } ... on Bot { login } } }
|
||||
... on UnlabeledEvent { createdAt actor { ...ActorFields } label { name color } }
|
||||
... on UnlockedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnmarkedAsDuplicateEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnpinnedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnsubscribedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UserBlockedEvent { createdAt actor { ...ActorFields } blockDuration }
|
||||
}
|
||||
"""
|
||||
|
||||
F_PR_TIMELINE = """
|
||||
fragment PRTimelineItem on PullRequestTimelineItems {
|
||||
__typename
|
||||
... on Node { id }
|
||||
... on AssignedEvent { createdAt actor { ...ActorFields } assignee { __typename ... on User { login } ... on Bot { login } } }
|
||||
... on AutoMergeDisabledEvent { createdAt actor { ...ActorFields } reason }
|
||||
... on AutoMergeEnabledEvent { createdAt actor { ...ActorFields } }
|
||||
... on AutoRebaseEnabledEvent { createdAt actor { ...ActorFields } }
|
||||
... on AutoSquashEnabledEvent { createdAt actor { ...ActorFields } }
|
||||
... on AutomaticBaseChangeFailedEvent { createdAt actor { ...ActorFields } oldBase newBase }
|
||||
... on AutomaticBaseChangeSucceededEvent { createdAt actor { ...ActorFields } oldBase newBase }
|
||||
... on BaseRefChangedEvent { createdAt actor { ...ActorFields } previousRefName currentRefName }
|
||||
... on BaseRefDeletedEvent { createdAt actor { ...ActorFields } baseRefName }
|
||||
... on BaseRefForcePushedEvent { createdAt actor { ...ActorFields } beforeCommit { oid } afterCommit { oid } ref { name } }
|
||||
... on ClosedEvent { createdAt actor { ...ActorFields } stateReason }
|
||||
... on CommentDeletedEvent { createdAt actor { ...ActorFields } }
|
||||
... on ConnectedEvent { createdAt actor { ...ActorFields } source { __typename ... on Issue { number url } ... on PullRequest { number url } } subject { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on ConvertToDraftEvent { createdAt actor { ...ActorFields } }
|
||||
... on CrossReferencedEvent { createdAt actor { ...ActorFields } isCrossRepository willCloseTarget source { __typename ... on Issue { number url repository { nameWithOwner } title } ... on PullRequest { number url repository { nameWithOwner } title } } }
|
||||
... on DemilestonedEvent { createdAt actor { ...ActorFields } milestoneTitle }
|
||||
... on DeployedEvent { createdAt actor { ...ActorFields } }
|
||||
... on DeploymentEnvironmentChangedEvent { createdAt actor { ...ActorFields } }
|
||||
... on DisconnectedEvent { createdAt actor { ...ActorFields } subject { __typename ... on Issue { number url } ... on PullRequest { number url } } source { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on HeadRefDeletedEvent { createdAt actor { ...ActorFields } headRefName }
|
||||
... on HeadRefForcePushedEvent { createdAt actor { ...ActorFields } beforeCommit { oid } afterCommit { oid } ref { name } }
|
||||
... on HeadRefRestoredEvent { createdAt actor { ...ActorFields } }
|
||||
... on IssueComment { id databaseId createdAt updatedAt author { ...ActorFields } body url reactionGroups { content reactors { totalCount } } }
|
||||
... on LabeledEvent { createdAt actor { ...ActorFields } label { name color } }
|
||||
... on LockedEvent { createdAt actor { ...ActorFields } lockReason }
|
||||
... on MarkedAsDuplicateEvent { createdAt actor { ...ActorFields } canonical { __typename ... on Issue { number url } ... on PullRequest { number url } } }
|
||||
... on MentionedEvent { createdAt actor { ...ActorFields } }
|
||||
... on MergedEvent { createdAt actor { ...ActorFields } commit { oid url } mergeRefName }
|
||||
... on MilestonedEvent { createdAt actor { ...ActorFields } milestoneTitle }
|
||||
... on MovedColumnsInProjectEvent { createdAt actor { ...ActorFields } }
|
||||
... on PinnedEvent { createdAt actor { ...ActorFields } }
|
||||
... on PullRequestCommit { commit { oid url message author { user { login } date } committedDate } }
|
||||
... on PullRequestCommitCommentThread { commit { oid } }
|
||||
... on PullRequestReview { id databaseId createdAt submittedAt author { ...ActorFields } body state url reactionGroups { content reactors { totalCount } } }
|
||||
... on PullRequestReviewThread { id isResolved isOutdated path line diffSide }
|
||||
... on PullRequestRevisionMarker { createdAt lastSeenCommit { oid } }
|
||||
... on ReadyForReviewEvent { createdAt actor { ...ActorFields } }
|
||||
... on ReferencedEvent { createdAt actor { ...ActorFields } commit { oid url } commitRepository { nameWithOwner } }
|
||||
... on RenamedTitleEvent { createdAt actor { ...ActorFields } previousTitle currentTitle }
|
||||
... on ReopenedEvent { createdAt actor { ...ActorFields } }
|
||||
... on ReviewDismissedEvent { createdAt actor { ...ActorFields } dismissalMessage previousReviewState }
|
||||
... on ReviewRequestRemovedEvent { createdAt actor { ...ActorFields } requestedReviewer { __typename ... on User { login } ... on Team { name } } }
|
||||
... on ReviewRequestedEvent { createdAt actor { ...ActorFields } requestedReviewer { __typename ... on User { login } ... on Team { name } } }
|
||||
... on SubscribedEvent { createdAt actor { ...ActorFields } }
|
||||
... on TransferredEvent { createdAt actor { ...ActorFields } fromRepository { nameWithOwner } }
|
||||
... on UnassignedEvent { createdAt actor { ...ActorFields } assignee { __typename ... on User { login } ... on Bot { login } } }
|
||||
... on UnlabeledEvent { createdAt actor { ...ActorFields } label { name color } }
|
||||
... on UnlockedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnmarkedAsDuplicateEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnpinnedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UnsubscribedEvent { createdAt actor { ...ActorFields } }
|
||||
... on UserBlockedEvent { createdAt actor { ...ActorFields } blockDuration }
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _q(parts: list[str], body: str) -> str:
|
||||
return "\n".join(parts + [body])
|
||||
|
||||
|
||||
ISSUES_PAGE_QUERY = _q(
|
||||
[F_ACTOR, F_LABEL, F_TIMELINE],
|
||||
"""
|
||||
query IssuesPage($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issues(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body state stateReason
|
||||
createdAt updatedAt closedAt
|
||||
url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
labels(first: 50) { nodes { ...LabelFields } }
|
||||
assignees(first: 20) { nodes { login id } }
|
||||
milestone { title number state dueOn }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
comments(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
timelineItems(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...TimelineItem }
|
||||
}
|
||||
trackedInIssues(first: 20) { totalCount nodes { number url repository { nameWithOwner } } }
|
||||
trackedIssues(first: 20) { totalCount nodes { number url repository { nameWithOwner } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
PRS_PAGE_QUERY = _q(
|
||||
[F_ACTOR, F_LABEL, F_PR_TIMELINE],
|
||||
"""
|
||||
query PRsPage($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body state isDraft
|
||||
createdAt updatedAt closedAt mergedAt
|
||||
url
|
||||
headRefName headRefOid
|
||||
baseRefName baseRefOid
|
||||
additions deletions changedFiles
|
||||
mergeable merged mergeStateStatus
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
mergedBy { ...ActorFields }
|
||||
labels(first: 50) { nodes { ...LabelFields } }
|
||||
assignees(first: 20) { nodes { login id } }
|
||||
milestone { title number state dueOn }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
closingIssuesReferences(first: 20) { totalCount nodes { number url repository { nameWithOwner } title } }
|
||||
comments(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
reviewThreads(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id isResolved isOutdated path line diffSide
|
||||
comments(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body path diffHunk
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
position originalPosition line originalLine
|
||||
commit { oid }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
reviews(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId state createdAt submittedAt body url
|
||||
author { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
commits(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
commit {
|
||||
oid
|
||||
message
|
||||
messageHeadline
|
||||
committedDate
|
||||
authoredDate
|
||||
author { name email user { login } date }
|
||||
committer { name email user { login } date }
|
||||
additions deletions changedFilesIfAvailable
|
||||
parents(first: 3) { nodes { oid } }
|
||||
}
|
||||
}
|
||||
}
|
||||
files(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
path additions deletions changeType
|
||||
}
|
||||
}
|
||||
timelineItems(first: 100) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...PRTimelineItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
PRS_PAGE_QUERY_LIGHT = _q(
|
||||
[F_ACTOR, F_LABEL],
|
||||
"""
|
||||
query PRsPageLight($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequests(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body state isDraft
|
||||
createdAt updatedAt closedAt mergedAt
|
||||
url
|
||||
author { ...ActorFields }
|
||||
labels(first: 50) { nodes { ...LabelFields } }
|
||||
comments(first: 30) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
ISSUES_PAGE_QUERY_LIGHT = _q(
|
||||
[F_ACTOR, F_LABEL],
|
||||
"""
|
||||
query IssuesPageLight($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issues(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body state
|
||||
createdAt updatedAt closedAt
|
||||
url
|
||||
author { ...ActorFields }
|
||||
labels(first: 50) { nodes { ...LabelFields } }
|
||||
comments(first: 30) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
ISSUE_COMMENTS_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query IssueComments($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issueOrPullRequest(number: $number) {
|
||||
__typename
|
||||
... on Issue {
|
||||
comments(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
... on PullRequest {
|
||||
comments(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
ISSUE_TIMELINE_QUERY = _q(
|
||||
[F_ACTOR, F_TIMELINE],
|
||||
"""
|
||||
query IssueTimeline($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $number) {
|
||||
timelineItems(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...TimelineItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
PR_TIMELINE_QUERY = _q(
|
||||
[F_ACTOR, F_PR_TIMELINE],
|
||||
"""
|
||||
query PRTimeline($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
timelineItems(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...PRTimelineItem }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
PR_COMMITS_QUERY = """
|
||||
query PRCommits($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
commits(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
commit {
|
||||
oid message messageHeadline committedDate authoredDate
|
||||
author { name email user { login } date }
|
||||
committer { name email user { login } date }
|
||||
additions deletions changedFilesIfAvailable
|
||||
parents(first: 3) { nodes { oid } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
||||
PR_FILES_QUERY = """
|
||||
query PRFiles($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
files(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { path additions deletions changeType }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
||||
PR_REVIEW_THREADS_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query PRReviewThreads($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(first: 50, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id isResolved isOutdated path line diffSide
|
||||
comments(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId createdAt updatedAt url body path diffHunk
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
position originalPosition line originalLine
|
||||
commit { oid }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
DISCUSSIONS_PAGE_QUERY = _q(
|
||||
[F_ACTOR, F_LABEL],
|
||||
"""
|
||||
query DiscussionsPage($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
discussions(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId number title body
|
||||
createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
locked
|
||||
answerChosenAt
|
||||
closed closedAt
|
||||
category { id name emoji description isAnswerable }
|
||||
labels(first: 30) { nodes { ...LabelFields } }
|
||||
upvoteCount
|
||||
answer { id databaseId body author { ...ActorFields } createdAt url }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
comments(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
upvoteCount
|
||||
isAnswer
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
replies(first: 50) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
DISCUSSION_COMMENTS_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query DiscussionComments($owner: String!, $name: String!, $number: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
discussion(number: $number) {
|
||||
comments(first: 50, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
upvoteCount
|
||||
isAnswer
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
replies(first: 50) {
|
||||
totalCount
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
DISCUSSION_REPLIES_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query DiscussionReplies($commentId: ID!, $after: String) {
|
||||
node(id: $commentId) {
|
||||
... on DiscussionComment {
|
||||
replies(first: 50, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId body createdAt updatedAt url
|
||||
author { ...ActorFields }
|
||||
editor { ...ActorFields }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
COMMITS_PAGE_QUERY = """
|
||||
query CommitsPage($owner: String!, $name: String!, $first: Int!, $after: String, $branch: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
ref(qualifiedName: $branch) {
|
||||
target {
|
||||
... on Commit {
|
||||
history(first: $first, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
totalCount
|
||||
nodes {
|
||||
oid
|
||||
message
|
||||
messageHeadline
|
||||
committedDate
|
||||
authoredDate
|
||||
url
|
||||
additions deletions changedFilesIfAvailable
|
||||
author { name email date user { login id } }
|
||||
committer { name email date user { login id } }
|
||||
parents(first: 3) { nodes { oid } }
|
||||
associatedPullRequests(first: 5) { nodes { number url state } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
||||
RELEASES_QUERY = _q(
|
||||
[F_ACTOR],
|
||||
"""
|
||||
query Releases($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
releases(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: ASC}) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id databaseId name tagName description
|
||||
createdAt publishedAt updatedAt
|
||||
isDraft isPrerelease isLatest
|
||||
url
|
||||
author { ...ActorFields }
|
||||
tagCommit { oid url }
|
||||
reactionGroups { content reactors { totalCount } }
|
||||
releaseAssets(first: 50) {
|
||||
nodes { name contentType size downloadUrl createdAt updatedAt }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
LABELS_QUERY = _q(
|
||||
[F_LABEL],
|
||||
"""
|
||||
query LabelsList($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
labels(first: $first, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes { ...LabelFields }
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
""",
|
||||
)
|
||||
|
||||
MILESTONES_QUERY = """
|
||||
query Milestones($owner: String!, $name: String!, $first: Int!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
milestones(first: $first, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id number title description state
|
||||
createdAt updatedAt closedAt dueOn
|
||||
creator { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
||||
REPO_META_QUERY = """
|
||||
query RepoMeta($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
id databaseId name nameWithOwner description url
|
||||
createdAt updatedAt pushedAt
|
||||
isArchived isDisabled isFork isPrivate
|
||||
primaryLanguage { name }
|
||||
languages(first: 20, orderBy: {field: SIZE, direction: DESC}) {
|
||||
edges { size node { name } }
|
||||
totalSize
|
||||
}
|
||||
stargazerCount forkCount watchers { totalCount }
|
||||
diskUsage
|
||||
licenseInfo { key name }
|
||||
homepageUrl
|
||||
defaultBranchRef { name }
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
|
|
@ -0,0 +1,756 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Main scraper orchestration. Collects issues, PRs, discussions, commits, releases, etc.
|
||||
|
||||
Resumable via state file. Writes JSONL shards under data/{repo}/{resource}.jsonl.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
# Allow running as a module or script
|
||||
THIS_DIR = Path(__file__).resolve().parent
|
||||
if str(THIS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(THIS_DIR))
|
||||
|
||||
from gh_client import GitHubClient
|
||||
from state_store import JsonlWriter, StateStore
|
||||
import queries as Q
|
||||
|
||||
log = logging.getLogger("scraper")
|
||||
|
||||
|
||||
def ts() -> str:
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
class RepoScraper:
|
||||
def __init__(
|
||||
self,
|
||||
owner: str,
|
||||
name: str,
|
||||
base_dir: Path,
|
||||
client: GitHubClient,
|
||||
trial_limits: Optional[Dict[str, int]] = None,
|
||||
light: bool = False,
|
||||
):
|
||||
self.owner = owner
|
||||
self.name = name
|
||||
self.base_dir = base_dir
|
||||
self.client = client
|
||||
self.trial_limits = trial_limits or {}
|
||||
# When light=True, use trimmed GraphQL queries (no reviewThreads,
|
||||
# reviews, commits, timelineItems, files) so PR pages can be much
|
||||
# larger without blowing GitHub's node-count ceiling.
|
||||
self.light = light
|
||||
self.repo_dir = base_dir / f"{owner}__{name}"
|
||||
self.repo_dir.mkdir(parents = True, exist_ok = True)
|
||||
self.state = StateStore(base_dir / "state" / f"{owner}__{name}.json")
|
||||
|
||||
# Writers
|
||||
self.writers: Dict[str, JsonlWriter] = {}
|
||||
for key in (
|
||||
"issues",
|
||||
"pull_requests",
|
||||
"discussions",
|
||||
"commits",
|
||||
"releases",
|
||||
"labels",
|
||||
"milestones",
|
||||
"pr_extra_comments",
|
||||
"pr_extra_timeline",
|
||||
"pr_extra_reviews",
|
||||
"issue_extra_comments",
|
||||
"issue_extra_timeline",
|
||||
"discussion_extra_comments",
|
||||
"discussion_extra_replies",
|
||||
"repo_meta",
|
||||
):
|
||||
self.writers[key] = JsonlWriter(self.repo_dir / f"{key}.jsonl")
|
||||
|
||||
# ----- helpers -----
|
||||
def _trial_stop(self, key: str, counter: int) -> bool:
|
||||
lim = self.trial_limits.get(key)
|
||||
if lim is None:
|
||||
return False
|
||||
return counter >= lim
|
||||
|
||||
def _log_rate(self, where: str, data: Dict[str, Any]) -> None:
|
||||
rl = (
|
||||
data.get("data", {}).get("rateLimit")
|
||||
if isinstance(data.get("data"), dict)
|
||||
else None
|
||||
)
|
||||
if rl:
|
||||
log.debug(
|
||||
"[%s] rate cost=%s remaining=%s resetAt=%s",
|
||||
where,
|
||||
rl.get("cost"),
|
||||
rl.get("remaining"),
|
||||
rl.get("resetAt"),
|
||||
)
|
||||
|
||||
# ----- repo meta -----
|
||||
def scrape_repo_meta(self) -> Dict[str, Any]:
|
||||
data = self.client.graphql(
|
||||
Q.REPO_META_QUERY, {"owner": self.owner, "name": self.name}
|
||||
)
|
||||
self._log_rate("repo_meta", data)
|
||||
repo = data.get("data", {}).get("repository") or {}
|
||||
repo["_fetchedAt"] = ts()
|
||||
self.writers["repo_meta"].write(repo)
|
||||
return repo
|
||||
|
||||
# ----- issues -----
|
||||
def scrape_issues(self) -> int:
|
||||
key = "issues"
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
log.info("%s/%s issues already complete", self.owner, self.name)
|
||||
return 0
|
||||
total_new = 0
|
||||
page = 0
|
||||
# Light query skips heavy nested fields; safe at 50 per page.
|
||||
# Clamp by trial_limit so e.g. limit=1 asks GitHub for first:1
|
||||
# instead of fetching a full 50-item page and discarding 49.
|
||||
page_cap = 50 if self.light else 15
|
||||
trial_cap = self.trial_limits.get(key)
|
||||
per_page = min(page_cap, trial_cap) if trial_cap and trial_cap > 0 else page_cap
|
||||
while True:
|
||||
page += 1
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": per_page,
|
||||
"after": cursor,
|
||||
}
|
||||
query = Q.ISSUES_PAGE_QUERY_LIGHT if self.light else Q.ISSUES_PAGE_QUERY
|
||||
data = self.client.graphql(query, vars_)
|
||||
self._log_rate("issues", data)
|
||||
repo = (data.get("data") or {}).get("repository") or {}
|
||||
issues = repo.get("issues") or {}
|
||||
nodes = issues.get("nodes") or []
|
||||
for it in nodes:
|
||||
it["_owner"] = self.owner
|
||||
it["_repo"] = self.name
|
||||
it["_fetchedAt"] = ts()
|
||||
if not self.light:
|
||||
if it.get("comments", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_issue_comments(
|
||||
it["number"], it["comments"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if (
|
||||
it.get("timelineItems", {})
|
||||
.get("pageInfo", {})
|
||||
.get("hasNextPage")
|
||||
):
|
||||
self._paginate_issue_timeline(
|
||||
it["number"],
|
||||
it["timelineItems"]["pageInfo"]["endCursor"],
|
||||
)
|
||||
if self.writers[key].write(it):
|
||||
total_new += 1
|
||||
info = issues.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
log.info(
|
||||
"[%s/%s] issues page %d (+%d) cursor=%s remaining=%s",
|
||||
self.owner,
|
||||
self.name,
|
||||
page,
|
||||
len(nodes),
|
||||
str(cursor)[:20],
|
||||
self.client.graphql_remaining,
|
||||
)
|
||||
if self._trial_stop(key, total_new):
|
||||
log.info("Trial limit reached for issues (%d)", total_new)
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
return total_new
|
||||
|
||||
def _paginate_issue_comments(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.ISSUE_COMMENTS_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"issueOrPullRequest"
|
||||
) or {}
|
||||
comments = item.get("comments") or {}
|
||||
for c in comments.get("nodes") or []:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_issueNumber"] = number
|
||||
self.writers["issue_extra_comments"].write(c)
|
||||
info = comments.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_issue_timeline(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.ISSUE_TIMELINE_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get("issue") or {}
|
||||
tl = item.get("timelineItems") or {}
|
||||
for ev in tl.get("nodes") or []:
|
||||
ev["_owner"] = self.owner
|
||||
ev["_repo"] = self.name
|
||||
ev["_issueNumber"] = number
|
||||
self.writers["issue_extra_timeline"].write(ev)
|
||||
info = tl.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
# ----- PRs -----
|
||||
def scrape_prs(self) -> int:
|
||||
key = "pull_requests"
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
log.info("%s/%s PRs already complete", self.owner, self.name)
|
||||
return 0
|
||||
total_new = 0
|
||||
page = 0
|
||||
# Heavy nested PR query is capped at 3 per page (GitHub node-count
|
||||
# ceiling); light query skips reviewThreads/reviews/commits/etc and
|
||||
# can safely go to 25 per page. Clamp by trial_limit for small
|
||||
# previews so limit=1 does not fetch a whole 25-item page.
|
||||
page_cap = 25 if self.light else 3
|
||||
trial_cap = self.trial_limits.get(key)
|
||||
per_page = min(page_cap, trial_cap) if trial_cap and trial_cap > 0 else page_cap
|
||||
while True:
|
||||
page += 1
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": per_page,
|
||||
"after": cursor,
|
||||
}
|
||||
query = Q.PRS_PAGE_QUERY_LIGHT if self.light else Q.PRS_PAGE_QUERY
|
||||
data = self.client.graphql(query, vars_)
|
||||
self._log_rate("prs", data)
|
||||
repo = (data.get("data") or {}).get("repository") or {}
|
||||
prs = repo.get("pullRequests") or {}
|
||||
nodes = prs.get("nodes") or []
|
||||
for pr in nodes:
|
||||
pr["_owner"] = self.owner
|
||||
pr["_repo"] = self.name
|
||||
pr["_fetchedAt"] = ts()
|
||||
num = pr["number"]
|
||||
if not self.light:
|
||||
if pr.get("comments", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_pr_comments(
|
||||
num, pr["comments"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if (
|
||||
pr.get("timelineItems", {})
|
||||
.get("pageInfo", {})
|
||||
.get("hasNextPage")
|
||||
):
|
||||
self._paginate_pr_timeline(
|
||||
num, pr["timelineItems"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if pr.get("commits", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_pr_commits(
|
||||
num, pr["commits"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if pr.get("files", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_pr_files(
|
||||
num, pr["files"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if (
|
||||
pr.get("reviewThreads", {})
|
||||
.get("pageInfo", {})
|
||||
.get("hasNextPage")
|
||||
):
|
||||
self._paginate_pr_review_threads(
|
||||
num, pr["reviewThreads"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
if self.writers[key].write(pr):
|
||||
total_new += 1
|
||||
info = prs.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
log.info(
|
||||
"[%s/%s] PRs page %d (+%d) cursor=%s remaining=%s",
|
||||
self.owner,
|
||||
self.name,
|
||||
page,
|
||||
len(nodes),
|
||||
str(cursor)[:20],
|
||||
self.client.graphql_remaining,
|
||||
)
|
||||
if self._trial_stop(key, total_new):
|
||||
log.info("Trial limit reached for PRs (%d)", total_new)
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
return total_new
|
||||
|
||||
def _paginate_pr_comments(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.ISSUE_COMMENTS_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"issueOrPullRequest"
|
||||
) or {}
|
||||
comments = item.get("comments") or {}
|
||||
for c in comments.get("nodes") or []:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_prNumber"] = number
|
||||
self.writers["pr_extra_comments"].write(c)
|
||||
info = comments.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_pr_timeline(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.PR_TIMELINE_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"pullRequest"
|
||||
) or {}
|
||||
tl = item.get("timelineItems") or {}
|
||||
for ev in tl.get("nodes") or []:
|
||||
ev["_owner"] = self.owner
|
||||
ev["_repo"] = self.name
|
||||
ev["_prNumber"] = number
|
||||
self.writers["pr_extra_timeline"].write(ev)
|
||||
info = tl.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_pr_commits(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
out_key = "pr_extra_commits"
|
||||
if out_key not in self.writers:
|
||||
self.writers[out_key] = JsonlWriter(self.repo_dir / f"{out_key}.jsonl")
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.PR_COMMITS_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"pullRequest"
|
||||
) or {}
|
||||
cc = item.get("commits") or {}
|
||||
for c in cc.get("nodes") or []:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_prNumber"] = number
|
||||
self.writers[out_key].write(c)
|
||||
info = cc.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_pr_files(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
out_key = "pr_extra_files"
|
||||
if out_key not in self.writers:
|
||||
self.writers[out_key] = JsonlWriter(self.repo_dir / f"{out_key}.jsonl")
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.PR_FILES_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"pullRequest"
|
||||
) or {}
|
||||
ff = item.get("files") or {}
|
||||
for f in ff.get("nodes") or []:
|
||||
f["_owner"] = self.owner
|
||||
f["_repo"] = self.name
|
||||
f["_prNumber"] = number
|
||||
# files don't have id, synthesize one
|
||||
f["_syntheticId"] = f"{self.owner}/{self.name}#{number}:{f.get('path')}"
|
||||
self.writers[out_key].write(f)
|
||||
info = ff.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_pr_review_threads(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
out_key = "pr_extra_review_threads"
|
||||
if out_key not in self.writers:
|
||||
self.writers[out_key] = JsonlWriter(self.repo_dir / f"{out_key}.jsonl")
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.PR_REVIEW_THREADS_QUERY, vars_)
|
||||
item = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"pullRequest"
|
||||
) or {}
|
||||
rt = item.get("reviewThreads") or {}
|
||||
for th in rt.get("nodes") or []:
|
||||
th["_owner"] = self.owner
|
||||
th["_repo"] = self.name
|
||||
th["_prNumber"] = number
|
||||
self.writers[out_key].write(th)
|
||||
info = rt.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
# ----- Discussions -----
|
||||
def scrape_discussions(self) -> int:
|
||||
key = "discussions"
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
log.info("%s/%s discussions already complete", self.owner, self.name)
|
||||
return 0
|
||||
total_new = 0
|
||||
page = 0
|
||||
per_page = 15
|
||||
while True:
|
||||
page += 1
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": per_page,
|
||||
"after": cursor,
|
||||
}
|
||||
data = self.client.graphql(Q.DISCUSSIONS_PAGE_QUERY, vars_)
|
||||
self._log_rate("discussions", data)
|
||||
repo = (data.get("data") or {}).get("repository") or {}
|
||||
dd = repo.get("discussions") or {}
|
||||
nodes = dd.get("nodes") or []
|
||||
for d in nodes:
|
||||
d["_owner"] = self.owner
|
||||
d["_repo"] = self.name
|
||||
d["_fetchedAt"] = ts()
|
||||
num = d["number"]
|
||||
if d.get("comments", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_discussion_comments(
|
||||
num, d["comments"]["pageInfo"]["endCursor"]
|
||||
)
|
||||
# paginate replies per comment if needed
|
||||
for c in d.get("comments", {}).get("nodes", []) or []:
|
||||
if c.get("replies", {}).get("pageInfo", {}).get("hasNextPage"):
|
||||
self._paginate_discussion_replies(
|
||||
c["id"], c["replies"]["pageInfo"]["endCursor"], num
|
||||
)
|
||||
if self.writers[key].write(d):
|
||||
total_new += 1
|
||||
info = dd.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
log.info(
|
||||
"[%s/%s] discussions page %d (+%d) cursor=%s remaining=%s",
|
||||
self.owner,
|
||||
self.name,
|
||||
page,
|
||||
len(nodes),
|
||||
str(cursor)[:20],
|
||||
self.client.graphql_remaining,
|
||||
)
|
||||
if self._trial_stop(key, total_new):
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
return total_new
|
||||
|
||||
def _paginate_discussion_comments(self, number: int, after: str) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"number": number,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.DISCUSSION_COMMENTS_QUERY, vars_)
|
||||
disc = ((data.get("data") or {}).get("repository") or {}).get(
|
||||
"discussion"
|
||||
) or {}
|
||||
cc = disc.get("comments") or {}
|
||||
for c in cc.get("nodes") or []:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_discussionNumber"] = number
|
||||
self.writers["discussion_extra_comments"].write(c)
|
||||
info = cc.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
def _paginate_discussion_replies(
|
||||
self, comment_id: str, after: str, disc_number: int
|
||||
) -> None:
|
||||
cur = after
|
||||
while cur:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"commentId": comment_id,
|
||||
"after": cur,
|
||||
}
|
||||
data = self.client.graphql(Q.DISCUSSION_REPLIES_QUERY, vars_)
|
||||
node = (data.get("data") or {}).get("node") or {}
|
||||
replies = node.get("replies") or {}
|
||||
for r in replies.get("nodes") or []:
|
||||
r["_owner"] = self.owner
|
||||
r["_repo"] = self.name
|
||||
r["_discussionNumber"] = disc_number
|
||||
r["_commentId"] = comment_id
|
||||
self.writers["discussion_extra_replies"].write(r)
|
||||
info = replies.get("pageInfo") or {}
|
||||
cur = info.get("endCursor") if info.get("hasNextPage") else None
|
||||
|
||||
# ----- Commits -----
|
||||
def scrape_commits(self, branch: str = "refs/heads/main") -> int:
|
||||
key = "commits"
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
return 0
|
||||
total_new = 0
|
||||
page = 0
|
||||
page_cap = 100
|
||||
trial_cap = self.trial_limits.get(key)
|
||||
per_page = min(page_cap, trial_cap) if trial_cap and trial_cap > 0 else page_cap
|
||||
while True:
|
||||
page += 1
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": per_page,
|
||||
"after": cursor,
|
||||
"branch": branch,
|
||||
}
|
||||
data = self.client.graphql(Q.COMMITS_PAGE_QUERY, vars_)
|
||||
self._log_rate("commits", data)
|
||||
ref = ((data.get("data") or {}).get("repository") or {}).get("ref") or {}
|
||||
tgt = ref.get("target") or {}
|
||||
hist = tgt.get("history") or {}
|
||||
nodes = hist.get("nodes") or []
|
||||
for c in nodes:
|
||||
c["_owner"] = self.owner
|
||||
c["_repo"] = self.name
|
||||
c["_fetchedAt"] = ts()
|
||||
if self.writers[key].write(c):
|
||||
total_new += 1
|
||||
info = hist.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
log.info(
|
||||
"[%s/%s] commits page %d (+%d) remaining=%s",
|
||||
self.owner,
|
||||
self.name,
|
||||
page,
|
||||
len(nodes),
|
||||
self.client.graphql_remaining,
|
||||
)
|
||||
if self._trial_stop(key, total_new):
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
return total_new
|
||||
|
||||
# ----- Releases/Labels/Milestones -----
|
||||
def scrape_releases(self) -> int:
|
||||
return self._scrape_simple("releases", Q.RELEASES_QUERY, "releases")
|
||||
|
||||
def scrape_labels(self) -> int:
|
||||
return self._scrape_simple("labels", Q.LABELS_QUERY, "labels")
|
||||
|
||||
def scrape_milestones(self) -> int:
|
||||
return self._scrape_simple("milestones", Q.MILESTONES_QUERY, "milestones")
|
||||
|
||||
def _scrape_simple(self, key: str, query: str, field: str) -> int:
|
||||
cursor = self.state.get(f"{key}_cursor")
|
||||
done = self.state.get(f"{key}_done", False)
|
||||
if done:
|
||||
return 0
|
||||
total_new = 0
|
||||
while True:
|
||||
vars_ = {
|
||||
"owner": self.owner,
|
||||
"name": self.name,
|
||||
"first": 50,
|
||||
"after": cursor,
|
||||
}
|
||||
data = self.client.graphql(query, vars_)
|
||||
repo = (data.get("data") or {}).get("repository") or {}
|
||||
col = repo.get(field) or {}
|
||||
for it in col.get("nodes") or []:
|
||||
it["_owner"] = self.owner
|
||||
it["_repo"] = self.name
|
||||
it["_fetchedAt"] = ts()
|
||||
if self.writers[key].write(it):
|
||||
total_new += 1
|
||||
info = col.get("pageInfo") or {}
|
||||
cursor = info.get("endCursor")
|
||||
self.state.set(f"{key}_cursor", cursor)
|
||||
if self._trial_stop(key, total_new):
|
||||
return total_new
|
||||
if not info.get("hasNextPage"):
|
||||
self.state.set(f"{key}_done", True)
|
||||
break
|
||||
log.info("[%s/%s] %s done +%d", self.owner, self.name, key, total_new)
|
||||
return total_new
|
||||
|
||||
def close(self) -> None:
|
||||
for w in self.writers.values():
|
||||
try:
|
||||
w.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def setup_logging(log_file: Path) -> None:
|
||||
log_file.parent.mkdir(parents = True, exist_ok = True)
|
||||
fmt = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
||||
handlers = [
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(log_file, mode = "a", encoding = "utf-8"),
|
||||
]
|
||||
logging.basicConfig(level = logging.INFO, format = fmt, handlers = handlers, force = True)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument(
|
||||
"--base-dir", default = "/mnt/disks/unslothai/ubuntu/workspace_34/github_scraper"
|
||||
)
|
||||
ap.add_argument(
|
||||
"--repos", nargs = "+", default = ["unslothai/unsloth", "unslothai/unsloth-zoo"]
|
||||
)
|
||||
ap.add_argument("--trial", action = "store_true", help = "Small trial run")
|
||||
ap.add_argument(
|
||||
"--only",
|
||||
nargs = "+",
|
||||
default = None,
|
||||
help = "Only run these resource keys: issues,pulls,discussions,commits,releases,labels,milestones,meta",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--hf-upload-interval",
|
||||
type = int,
|
||||
default = 900,
|
||||
help = "Seconds between HF uploads (0 to disable)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
base = Path(args.base_dir)
|
||||
data_dir = base / "data"
|
||||
data_dir.mkdir(parents = True, exist_ok = True)
|
||||
setup_logging(base / "logs" / f"scraper_{time.strftime('%Y%m%d_%H%M%S')}.log")
|
||||
log.info("Scraper starting: repos=%s trial=%s", args.repos, args.trial)
|
||||
|
||||
client = GitHubClient(min_remaining_graphql = 80, min_remaining_rest = 80)
|
||||
rl = client.rate_snapshot()
|
||||
log.info(
|
||||
"Rate limit snapshot: %s",
|
||||
json.dumps(rl.get("resources", {}), default = str)[:400],
|
||||
)
|
||||
|
||||
# Start HF uploader in background if requested
|
||||
uploader = None
|
||||
if args.hf_upload_interval > 0:
|
||||
from hf_uploader import HFUploader
|
||||
|
||||
uploader = HFUploader(data_dir, interval_s = args.hf_upload_interval)
|
||||
uploader.start()
|
||||
|
||||
trial_limits = None
|
||||
if args.trial:
|
||||
trial_limits = {
|
||||
"issues": 5,
|
||||
"pull_requests": 5,
|
||||
"discussions": 3,
|
||||
"commits": 20,
|
||||
"releases": 3,
|
||||
"labels": 20,
|
||||
"milestones": 20,
|
||||
}
|
||||
|
||||
only = set(args.only or [])
|
||||
|
||||
try:
|
||||
for repo_spec in args.repos:
|
||||
owner, name = repo_spec.split("/")
|
||||
scraper = RepoScraper(owner, name, data_dir, client, trial_limits)
|
||||
try:
|
||||
repo_meta: Dict[str, Any] = {}
|
||||
if not only or "meta" in only or "commits" in only:
|
||||
repo_meta = scraper.scrape_repo_meta()
|
||||
if not only or "labels" in only:
|
||||
scraper.scrape_labels()
|
||||
if not only or "milestones" in only:
|
||||
scraper.scrape_milestones()
|
||||
if not only or "releases" in only:
|
||||
scraper.scrape_releases()
|
||||
if not only or "discussions" in only:
|
||||
scraper.scrape_discussions()
|
||||
if not only or "issues" in only:
|
||||
scraper.scrape_issues()
|
||||
if not only or "pulls" in only:
|
||||
scraper.scrape_prs()
|
||||
if not only or "commits" in only:
|
||||
default_ref = repo_meta.get("defaultBranchRef") or {}
|
||||
default_branch = (
|
||||
default_ref.get("name")
|
||||
if isinstance(default_ref, dict)
|
||||
else None
|
||||
)
|
||||
branch = (
|
||||
f"refs/heads/{default_branch}"
|
||||
if default_branch
|
||||
else "refs/heads/main"
|
||||
)
|
||||
scraper.scrape_commits(branch = branch)
|
||||
finally:
|
||||
scraper.close()
|
||||
finally:
|
||||
if uploader:
|
||||
log.info("Stopping uploader and final sync...")
|
||||
uploader.stop(final_upload = True)
|
||||
log.info(
|
||||
"Scraper complete. GraphQL calls=%d REST calls=%d",
|
||||
client.calls_graphql,
|
||||
client.calls_rest,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Checkpoint state management for resumable scraping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class StateStore:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents = True, exist_ok = True)
|
||||
self._lock = threading.Lock()
|
||||
self._data: Dict[str, Any] = {}
|
||||
if self.path.exists():
|
||||
try:
|
||||
with self.path.open() as f:
|
||||
self._data = json.load(f)
|
||||
except Exception:
|
||||
self._data = {}
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
with self._lock:
|
||||
return self._data.get(key, default)
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
with self._lock:
|
||||
self._data[key] = value
|
||||
self._flush()
|
||||
|
||||
def update(self, key: str, **kwargs) -> None:
|
||||
with self._lock:
|
||||
sub = dict(self._data.get(key, {}))
|
||||
sub.update(kwargs)
|
||||
self._data[key] = sub
|
||||
self._flush()
|
||||
|
||||
def all(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._data)
|
||||
|
||||
def _flush(self) -> None:
|
||||
tmp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
with tmp.open("w") as f:
|
||||
json.dump(self._data, f, indent = 2, default = str)
|
||||
os.replace(tmp, self.path)
|
||||
|
||||
|
||||
class JsonlWriter:
|
||||
"""Append-only JSONL writer, thread-safe, with line buffering."""
|
||||
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents = True, exist_ok = True)
|
||||
self._lock = threading.Lock()
|
||||
self._fh = self.path.open("a", buffering = 1)
|
||||
self._count_seen_keys: set[str] = set()
|
||||
# Preload seen keys if file exists (for dedup across resumes)
|
||||
if self.path.exists() and self.path.stat().st_size > 0:
|
||||
try:
|
||||
with self.path.open() as f:
|
||||
for line in f:
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
k = self._key(obj)
|
||||
if k is not None:
|
||||
self._count_seen_keys.add(k)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _key(self, obj: dict) -> str | None:
|
||||
for k in ("id", "node_id", "number", "sha", "url"):
|
||||
if k in obj:
|
||||
return f"{k}:{obj[k]}"
|
||||
return None
|
||||
|
||||
def has(self, key: str) -> bool:
|
||||
return key in self._count_seen_keys
|
||||
|
||||
def write(self, obj: dict) -> bool:
|
||||
"""Return True if newly written, False if already present."""
|
||||
k = self._key(obj)
|
||||
with self._lock:
|
||||
if k is not None and k in self._count_seen_keys:
|
||||
return False
|
||||
if k is not None:
|
||||
self._count_seen_keys.add(k)
|
||||
self._fh.write(json.dumps(obj, default = str, ensure_ascii = False))
|
||||
self._fh.write("\n")
|
||||
self._fh.flush()
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self._fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -2,9 +2,13 @@
|
|||
descript-audio-codec
|
||||
descript-audiotools
|
||||
julius
|
||||
torchcodec
|
||||
torchcodec==0.10.0
|
||||
snac
|
||||
|
||||
# peft 0.19.0 causes export subprocess shutdown issues in Studio;
|
||||
# installing with --no-deps to avoid pulling in torch>=0.11.0
|
||||
peft==0.18.1
|
||||
|
||||
# TRL and related packages
|
||||
trl==0.23.1
|
||||
git+https://github.com/meta-pytorch/OpenEnv.git
|
||||
|
|
@ -13,4 +17,4 @@ torch-c-dlpack-ext
|
|||
sentence_transformers==5.2.0
|
||||
transformers==4.57.6
|
||||
pytorch_tokenizers
|
||||
kernels
|
||||
kernels==0.12.1
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ ruff<1,>=0.14.10
|
|||
scipy<2,>=1.11.0
|
||||
sqlfluff<4,>=3.2.0
|
||||
tiktoken<1,>=0.8.0
|
||||
# Unstructured-seed plugin deps (plugin installed with --no-deps)
|
||||
# Local seed plugin deps (plugins installed with --no-deps)
|
||||
requests>=2.31
|
||||
pymupdf>=1.24.0
|
||||
pymupdf4llm>=0.0.17
|
||||
mammoth>=1.8.0
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ API Routes
|
|||
from routes.training import router as training_router
|
||||
from routes.models import router as models_router
|
||||
from routes.inference import router as inference_router
|
||||
from routes.inference import studio_router as inference_studio_router
|
||||
from routes.datasets import router as datasets_router
|
||||
from routes.auth import router as auth_router
|
||||
from routes.data_recipe import router as data_recipe_router
|
||||
|
|
@ -19,6 +20,7 @@ __all__ = [
|
|||
"training_router",
|
||||
"models_router",
|
||||
"inference_router",
|
||||
"inference_studio_router",
|
||||
"datasets_router",
|
||||
"auth_router",
|
||||
"data_recipe_router",
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@ Authentication API routes
|
|||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from models.auth import (
|
||||
ApiKeyListResponse,
|
||||
ApiKeyResponse,
|
||||
AuthLoginRequest,
|
||||
RefreshTokenRequest,
|
||||
AuthStatusResponse,
|
||||
ChangePasswordRequest,
|
||||
CreateApiKeyRequest,
|
||||
CreateApiKeyResponse,
|
||||
DesktopLoginRequest,
|
||||
RefreshTokenRequest,
|
||||
)
|
||||
from models.users import Token
|
||||
from auth import storage, hashing
|
||||
|
|
@ -74,6 +81,24 @@ async def login(payload: AuthLoginRequest) -> Token:
|
|||
)
|
||||
|
||||
|
||||
@router.post("/desktop-login", response_model = Token)
|
||||
async def desktop_login(payload: DesktopLoginRequest) -> Token:
|
||||
"""Exchange a local desktop secret for normal admin-subject tokens."""
|
||||
username = storage.validate_desktop_secret(payload.secret)
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Desktop authentication failed",
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token = create_access_token(subject = username, desktop = True),
|
||||
refresh_token = create_refresh_token(subject = username, desktop = True),
|
||||
token_type = "bearer",
|
||||
must_change_password = False,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model = Token)
|
||||
async def refresh(payload: RefreshTokenRequest) -> Token:
|
||||
"""
|
||||
|
|
@ -81,7 +106,7 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
|
|||
|
||||
The refresh token itself is reusable until it expires (7 days).
|
||||
"""
|
||||
new_access_token, username = refresh_access_token(payload.refresh_token)
|
||||
new_access_token, username, is_desktop = refresh_access_token(payload.refresh_token)
|
||||
if new_access_token is None or username is None:
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -92,7 +117,9 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
|
|||
access_token = new_access_token,
|
||||
refresh_token = payload.refresh_token,
|
||||
token_type = "bearer",
|
||||
must_change_password = storage.requires_password_change(username),
|
||||
must_change_password = False
|
||||
if is_desktop
|
||||
else storage.requires_password_change(username),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -131,3 +158,68 @@ async def change_password(
|
|||
token_type = "bearer",
|
||||
must_change_password = False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API key management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _row_to_api_key_response(row: dict) -> ApiKeyResponse:
|
||||
return ApiKeyResponse(
|
||||
id = row["id"],
|
||||
name = row["name"],
|
||||
key_prefix = row["key_prefix"],
|
||||
created_at = row["created_at"],
|
||||
last_used_at = row.get("last_used_at"),
|
||||
expires_at = row.get("expires_at"),
|
||||
is_active = bool(row["is_active"]),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api-keys", response_model = CreateApiKeyResponse)
|
||||
async def create_api_key(
|
||||
payload: CreateApiKeyRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> CreateApiKeyResponse:
|
||||
"""Create a new API key. The raw key is returned once and cannot be retrieved later."""
|
||||
expires_at = None
|
||||
if payload.expires_in_days is not None:
|
||||
expires_at = (
|
||||
datetime.now(timezone.utc) + timedelta(days = payload.expires_in_days)
|
||||
).isoformat()
|
||||
|
||||
raw_key, row = storage.create_api_key(
|
||||
username = current_subject,
|
||||
name = payload.name,
|
||||
expires_at = expires_at,
|
||||
)
|
||||
return CreateApiKeyResponse(
|
||||
key = raw_key,
|
||||
api_key = _row_to_api_key_response(row),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api-keys", response_model = ApiKeyListResponse)
|
||||
async def list_api_keys(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> ApiKeyListResponse:
|
||||
"""List all API keys for the authenticated user (raw keys are never exposed)."""
|
||||
rows = storage.list_api_keys(current_subject)
|
||||
return ApiKeyListResponse(
|
||||
api_keys = [_row_to_api_key_response(r) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api-keys/{key_id}")
|
||||
async def revoke_api_key(
|
||||
key_id: int,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> dict:
|
||||
"""Revoke (soft-delete) an API key."""
|
||||
if not storage.revoke_api_key(current_subject, key_id):
|
||||
raise HTTPException(
|
||||
status_code = status.HTTP_404_NOT_FOUND,
|
||||
detail = "API key not found",
|
||||
)
|
||||
return {"detail": "API key revoked"}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import copy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
|
@ -26,6 +29,308 @@ from models.data_recipe import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_local_v1_endpoint(request: Request) -> str:
|
||||
"""Return the loopback /v1 URL for the actual backend listen port.
|
||||
|
||||
Resolution order:
|
||||
1. ``app.state.server_port`` - explicitly published by run.py after
|
||||
the uvicorn server has bound. This is the most reliable source
|
||||
because it survives reverse proxies, TLS terminators and tunnels.
|
||||
2. ``request.scope["server"]`` - the real (host, port) tuple uvicorn
|
||||
sets when the request is dispatched. Used when Studio is started
|
||||
outside ``run_server`` (e.g. ``uvicorn studio.backend.main:app``).
|
||||
3. ``request.base_url`` parsed - last resort for test fixtures that
|
||||
do not route through a live uvicorn server.
|
||||
"""
|
||||
port: Any = getattr(request.app.state, "server_port", None)
|
||||
if not isinstance(port, int) or port <= 0:
|
||||
server = request.scope.get("server")
|
||||
if (
|
||||
isinstance(server, tuple)
|
||||
and len(server) >= 2
|
||||
and isinstance(server[1], int)
|
||||
and server[1] > 0
|
||||
):
|
||||
port = server[1]
|
||||
else:
|
||||
parsed = urlparse(str(request.base_url))
|
||||
port = parsed.port if parsed.port is not None else 8888
|
||||
return f"http://127.0.0.1:{int(port)}/v1"
|
||||
|
||||
|
||||
def _request_has_desktop_access_token(request: Request) -> bool:
|
||||
auth_header = request.headers.get("authorization")
|
||||
if not auth_header:
|
||||
return False
|
||||
|
||||
parts = auth_header.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return False
|
||||
|
||||
from auth.authentication import is_desktop_access_token
|
||||
|
||||
return is_desktop_access_token(parts[1])
|
||||
|
||||
|
||||
def _used_llm_model_aliases(recipe: dict[str, Any]) -> set[str]:
|
||||
"""Return the set of model_aliases that are actually referenced by an
|
||||
LLM column. Used to narrow the "Chat model loaded" gate so that orphan
|
||||
model_config nodes on the canvas do not block unrelated recipe runs.
|
||||
|
||||
The ``llm-`` prefix matches the existing convention in
|
||||
``core/data_recipe/service.py::_recipe_has_llm_columns`` and covers all
|
||||
LLM column types emitted by the frontend (llm-text, llm-code,
|
||||
llm-structured, llm-judge).
|
||||
"""
|
||||
aliases: set[str] = set()
|
||||
for column in recipe.get("columns", []):
|
||||
if not isinstance(column, dict):
|
||||
continue
|
||||
column_type = column.get("column_type")
|
||||
if not isinstance(column_type, str) or not column_type.startswith("llm-"):
|
||||
continue
|
||||
alias = column.get("model_alias")
|
||||
if isinstance(alias, str) and alias:
|
||||
aliases.add(alias)
|
||||
return aliases
|
||||
|
||||
|
||||
def _inject_local_structured_response_format(
|
||||
recipe: dict[str, Any], local_provider_names: set[str]
|
||||
) -> None:
|
||||
"""For each llm-structured column that targets a local-provider model_config,
|
||||
clone the model_config and inject an OpenAI ``response_format`` with the
|
||||
column's ``output_format`` JSON schema. The column is rewritten to point at
|
||||
the clone so llm-text / llm-judge columns that share the same alias keep
|
||||
free-form sampling.
|
||||
|
||||
Without this, data_designer only injects a prompt-level "return JSON in a
|
||||
```json fence" instruction. Small GGUF models frequently break format,
|
||||
wasting the full ``max_tokens`` budget per row and then failing to parse.
|
||||
Forwarding ``response_format`` lets llama-server apply grammar-constrained
|
||||
sampling from the JSON schema, which guarantees a parseable response and
|
||||
terminates early.
|
||||
"""
|
||||
columns = recipe.get("columns")
|
||||
model_configs = recipe.get("model_configs")
|
||||
if not isinstance(columns, list) or not isinstance(model_configs, list):
|
||||
return
|
||||
|
||||
# alias -> model_config (only configs referencing a local provider qualify).
|
||||
alias_to_local_mc: dict[str, dict[str, Any]] = {}
|
||||
for mc in model_configs:
|
||||
if not isinstance(mc, dict):
|
||||
continue
|
||||
if mc.get("provider") in local_provider_names and isinstance(
|
||||
mc.get("alias"), str
|
||||
):
|
||||
alias_to_local_mc[mc["alias"]] = mc
|
||||
|
||||
if not alias_to_local_mc:
|
||||
return
|
||||
|
||||
# Clone per (alias, column) so each llm-structured column gets its own
|
||||
# schema without leaking response_format onto other columns that share the
|
||||
# same base alias.
|
||||
seen_clone_aliases: set[str] = {
|
||||
mc.get("alias") for mc in model_configs if isinstance(mc.get("alias"), str)
|
||||
}
|
||||
new_configs: list[dict[str, Any]] = []
|
||||
for column in columns:
|
||||
if not isinstance(column, dict):
|
||||
continue
|
||||
if column.get("column_type") != "llm-structured":
|
||||
continue
|
||||
alias = column.get("model_alias")
|
||||
if not isinstance(alias, str) or alias not in alias_to_local_mc:
|
||||
continue
|
||||
output_format = column.get("output_format")
|
||||
if not isinstance(output_format, dict) or not output_format:
|
||||
continue
|
||||
base_mc = alias_to_local_mc[alias]
|
||||
column_name = column.get("name") or "structured"
|
||||
clone_alias_base = f"{alias}__{column_name}_structured"
|
||||
clone_alias = clone_alias_base
|
||||
counter = 1
|
||||
while clone_alias in seen_clone_aliases:
|
||||
counter += 1
|
||||
clone_alias = f"{clone_alias_base}_{counter}"
|
||||
seen_clone_aliases.add(clone_alias)
|
||||
|
||||
clone = copy.deepcopy(base_mc)
|
||||
clone["alias"] = clone_alias
|
||||
params = clone.get("inference_parameters")
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
clone["inference_parameters"] = params
|
||||
# data_designer's BaseInferenceParams is a pydantic model with
|
||||
# extra="forbid", so response_format cannot sit at the top level of
|
||||
# inference_parameters. It does expose an `extra_body: dict` pass-
|
||||
# through that the OpenAI client spreads into the request body at the
|
||||
# top level, which is where llama-server reads response_format from.
|
||||
# llama.cpp server shape (tools/server/README.md): the schema sits
|
||||
# directly under response_format, not nested in a json_schema object
|
||||
# the way OpenAI's Chat Completions API expects. llama-server converts
|
||||
# the schema to a GBNF grammar and applies it during sampling.
|
||||
extra_body = params.get("extra_body")
|
||||
if not isinstance(extra_body, dict):
|
||||
extra_body = {}
|
||||
extra_body["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"schema": output_format,
|
||||
}
|
||||
params["extra_body"] = extra_body
|
||||
new_configs.append(clone)
|
||||
column["model_alias"] = clone_alias
|
||||
|
||||
if new_configs:
|
||||
model_configs.extend(new_configs)
|
||||
|
||||
|
||||
def _inject_local_providers(recipe: dict[str, Any], request: Request) -> Optional[int]:
|
||||
"""
|
||||
Mutate recipe dict in-place: for any provider with is_local=True,
|
||||
fill in the endpoint pointing at this server and inject a short-lived
|
||||
internal sk-unsloth-* API key for workflow auth.
|
||||
|
||||
Returns the row id of the minted internal key (so the caller can
|
||||
revoke it on job completion) or ``None`` when no local provider is
|
||||
actually reachable from an LLM column.
|
||||
"""
|
||||
providers = recipe.get("model_providers")
|
||||
if not providers:
|
||||
return None
|
||||
|
||||
# Collect local providers and pop is_local from ALL dicts unconditionally.
|
||||
# Strict `is True` guard so malformed payloads (is_local: 1,
|
||||
# is_local: "true") do not accidentally trigger the loopback rewrite.
|
||||
local_indices: list[int] = []
|
||||
for i, provider in enumerate(providers):
|
||||
if not isinstance(provider, dict):
|
||||
continue
|
||||
is_local = provider.pop("is_local", None)
|
||||
if is_local is True:
|
||||
local_indices.append(i)
|
||||
|
||||
if not local_indices:
|
||||
return None
|
||||
|
||||
endpoint = _resolve_local_v1_endpoint(request)
|
||||
|
||||
# Only gate on model-loaded if a local provider is actually reachable
|
||||
# from an LLM column through a model_config. Orphan model_config nodes
|
||||
# that reference a local provider but that no LLM column uses should
|
||||
# not block runs; the recipe would never call /v1 for them.
|
||||
local_names = {
|
||||
providers[i].get("name") for i in local_indices if providers[i].get("name")
|
||||
}
|
||||
used_aliases = _used_llm_model_aliases(recipe)
|
||||
referenced_providers = {
|
||||
mc.get("provider")
|
||||
for mc in recipe.get("model_configs", [])
|
||||
if (
|
||||
isinstance(mc, dict)
|
||||
and mc.get("provider")
|
||||
and mc.get("alias") in used_aliases
|
||||
)
|
||||
}
|
||||
|
||||
token = ""
|
||||
internal_key_id: Optional[int] = None
|
||||
if local_names & referenced_providers:
|
||||
# Verify a model is loaded.
|
||||
# NOTE: This is a point-in-time check (TOCTOU). The model could be unloaded
|
||||
# or swapped after this check but before the recipe subprocess calls /v1.
|
||||
# The inference endpoint returns a clear 400 in that case.
|
||||
#
|
||||
# Imports are deferred to avoid circular dependencies with inference modules.
|
||||
from routes.inference import get_llama_cpp_backend
|
||||
from core.inference import get_inference_backend
|
||||
|
||||
llama = get_llama_cpp_backend()
|
||||
model_loaded = llama.is_loaded
|
||||
if not model_loaded:
|
||||
backend = get_inference_backend()
|
||||
model_loaded = bool(backend.active_model_name)
|
||||
if not model_loaded:
|
||||
raise ValueError(
|
||||
"No model loaded in Chat. Load a model first, then run the recipe."
|
||||
)
|
||||
|
||||
from auth import storage # deferred: avoids circular import
|
||||
|
||||
# Mint an internal sk-unsloth-* key scoped to this workflow run.
|
||||
# Uses the unified API-key issuance path (one mint/revoke/verify
|
||||
# surface instead of a second JWT code path). The key is marked
|
||||
# internal so it is hidden from the user's API-key list, and the
|
||||
# caller revokes it when the job terminates.
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(hours = 24)).isoformat()
|
||||
token, row = storage.create_api_key(
|
||||
username = "unsloth",
|
||||
name = "data-recipe workflow",
|
||||
expires_at = expires_at,
|
||||
internal = True,
|
||||
)
|
||||
internal_key_id = int(row["id"])
|
||||
|
||||
# Defensively strip any stale "external"-only fields the frontend may
|
||||
# have left on the dict (extra_headers/extra_body/api_key_env). The UI
|
||||
# hides these inputs in local mode but the payload builder still serializes
|
||||
# them, so a previously external provider that flipped to local can carry
|
||||
# invalid JSON or rogue auth headers into the local /v1 call.
|
||||
for i in local_indices:
|
||||
providers[i]["endpoint"] = endpoint
|
||||
providers[i]["api_key"] = token
|
||||
providers[i]["provider_type"] = "openai"
|
||||
providers[i].pop("api_key_env", None)
|
||||
providers[i].pop("extra_headers", None)
|
||||
providers[i].pop("extra_body", None)
|
||||
|
||||
# Force skip_health_check on any model_config that references a local
|
||||
# provider. The local /v1/models endpoint only lists the real loaded
|
||||
# model (e.g. "unsloth/llama-3.2-1b") and not the placeholder "local"
|
||||
# that the recipe sends as the model id, so data_designer's pre-flight
|
||||
# health check would otherwise fail before the first completion call.
|
||||
# The backend route ignores the model id field in chat completions, so
|
||||
# skipping the check is safe.
|
||||
for mc in recipe.get("model_configs", []):
|
||||
if not isinstance(mc, dict):
|
||||
continue
|
||||
if mc.get("provider") in local_names:
|
||||
mc["skip_health_check"] = True
|
||||
# Disable thinking for data-recipe inference on local providers.
|
||||
# Reasoning models emit a <think>...</think> preamble before the
|
||||
# answer, which roughly doubles generated token count per row and
|
||||
# pushes the visible answer past data_designer's json-fence
|
||||
# regex. Forward chat_template_kwargs={enable_thinking: False}
|
||||
# through the OpenAI SDK's extra_body passthrough so llama-server
|
||||
# renders the template without the reasoning preamble. Free-form
|
||||
# llm-text columns benefit from the latency cut, and structured
|
||||
# columns also stop leaking think tags into the grammar-
|
||||
# constrained JSON (llama-server's GBNF path still enforces the
|
||||
# schema either way).
|
||||
params = mc.get("inference_parameters")
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
mc["inference_parameters"] = params
|
||||
extra_body = params.get("extra_body")
|
||||
if not isinstance(extra_body, dict):
|
||||
extra_body = {}
|
||||
tpl_kwargs = extra_body.get("chat_template_kwargs")
|
||||
if not isinstance(tpl_kwargs, dict):
|
||||
tpl_kwargs = {}
|
||||
tpl_kwargs.setdefault("enable_thinking", False)
|
||||
extra_body["chat_template_kwargs"] = tpl_kwargs
|
||||
params["extra_body"] = extra_body
|
||||
|
||||
# Forward each llm-structured column's output_format as an OpenAI
|
||||
# response_format so llama-server uses grammar-constrained sampling and
|
||||
# small GGUFs stop wasting the full max_tokens budget on broken JSON.
|
||||
_inject_local_structured_response_format(recipe, local_names)
|
||||
|
||||
return internal_key_id
|
||||
|
||||
|
||||
def _normalize_run_name(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
|
@ -40,7 +345,7 @@ def _normalize_run_name(value: Any) -> str | None:
|
|||
|
||||
|
||||
@router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse)
|
||||
def create_job(payload: RecipePayload):
|
||||
def create_job(payload: RecipePayload, request: Request):
|
||||
recipe = payload.recipe
|
||||
if not recipe.get("columns"):
|
||||
raise HTTPException(status_code = 400, detail = "Recipe must include columns.")
|
||||
|
|
@ -67,17 +372,50 @@ def create_job(payload: RecipePayload):
|
|||
status_code = 400, detail = f"invalid run_config: {exc}"
|
||||
) from exc
|
||||
|
||||
mgr = get_job_manager()
|
||||
try:
|
||||
job_id = mgr.start(recipe = recipe, run = run)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code = 409, detail = str(exc)) from exc
|
||||
internal_api_key_id = _inject_local_providers(recipe, request)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code = 400, detail = str(exc)) from exc
|
||||
|
||||
# Single try block covers get_job_manager() AND mgr.start() so a workflow
|
||||
# key minted above never outlives the request even when an unexpected
|
||||
# exception type (TypeError from a stale kwarg, OSError from a queue
|
||||
# write, etc.) bubbles up. Without the bare except, such exceptions let
|
||||
# the sk-unsloth-* key live until its 24h TTL.
|
||||
try:
|
||||
mgr = get_job_manager()
|
||||
job_id = mgr.start(
|
||||
recipe = recipe,
|
||||
run = run,
|
||||
internal_api_key_id = internal_api_key_id,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if internal_api_key_id is not None:
|
||||
_revoke_internal_api_key_safe(internal_api_key_id)
|
||||
raise HTTPException(status_code = 409, detail = str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
if internal_api_key_id is not None:
|
||||
_revoke_internal_api_key_safe(internal_api_key_id)
|
||||
raise HTTPException(status_code = 400, detail = str(exc)) from exc
|
||||
except Exception:
|
||||
if internal_api_key_id is not None:
|
||||
_revoke_internal_api_key_safe(internal_api_key_id)
|
||||
raise
|
||||
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
def _revoke_internal_api_key_safe(key_id: int) -> None:
|
||||
"""Best-effort revoke of a workflow-minted key; swallow any error so
|
||||
that revocation failures never mask the caller's own error path."""
|
||||
try:
|
||||
from auth import storage # deferred: avoids circular import
|
||||
|
||||
storage.revoke_internal_api_key(key_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/status")
|
||||
def job_status(job_id: str):
|
||||
mgr = get_job_manager()
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
|
|
@ -627,3 +628,14 @@ def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectRespons
|
|||
split = None,
|
||||
subset = None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/seed/github/env-token")
|
||||
def get_github_env_token_status() -> dict:
|
||||
"""Report whether the server has a GH_TOKEN / GITHUB_TOKEN env var.
|
||||
|
||||
The value is never returned; the UI uses this to tell the user they
|
||||
can leave the token field blank.
|
||||
"""
|
||||
has_token = bool(os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN"))
|
||||
return {"has_token": has_token}
|
||||
|
|
|
|||
|
|
@ -14,10 +14,63 @@ from core.data_recipe.service import (
|
|||
create_data_designer,
|
||||
validate_recipe,
|
||||
)
|
||||
from loggers import get_logger
|
||||
from models.data_recipe import RecipePayload, ValidateError, ValidateResponse
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_GITHUB_VALIDATE_NOTE = "Recipe shape is valid. GitHub access and rate limits are checked when the run starts."
|
||||
_GITHUB_ITEM_TYPES = {"issues", "pulls", "commits"}
|
||||
|
||||
|
||||
def _github_seed_source(recipe: dict[str, Any]) -> dict[str, Any] | None:
|
||||
seed_config = recipe.get("seed_config")
|
||||
if not isinstance(seed_config, dict):
|
||||
return None
|
||||
source = seed_config.get("source")
|
||||
if not isinstance(source, dict) or source.get("seed_type") != "github_repo":
|
||||
return None
|
||||
return source
|
||||
|
||||
|
||||
def _validate_github_seed_static(source: dict[str, Any]) -> list[ValidateError]:
|
||||
errors: list[ValidateError] = []
|
||||
|
||||
repos = source.get("repos")
|
||||
if not isinstance(repos, list) or not repos:
|
||||
errors.append(ValidateError(message = "GitHub seed requires at least one repo."))
|
||||
else:
|
||||
for repo in repos:
|
||||
if not isinstance(repo, str) or not repo.strip() or "/" not in repo:
|
||||
errors.append(
|
||||
ValidateError(message = "GitHub repos must be owner/name strings.")
|
||||
)
|
||||
break
|
||||
|
||||
item_types = source.get("item_types")
|
||||
if not isinstance(item_types, list) or not item_types:
|
||||
errors.append(
|
||||
ValidateError(message = "GitHub seed requires at least one item type.")
|
||||
)
|
||||
else:
|
||||
invalid_items = [item for item in item_types if item not in _GITHUB_ITEM_TYPES]
|
||||
if invalid_items:
|
||||
errors.append(
|
||||
ValidateError(
|
||||
message = "GitHub item types must be issues, pulls, or commits."
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
limit = int(source.get("limit"))
|
||||
except (TypeError, ValueError):
|
||||
limit = 0
|
||||
if limit < 1 or limit > 5000:
|
||||
errors.append(ValidateError(message = "GitHub limit must be from 1 to 5000."))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
|
||||
try:
|
||||
|
|
@ -68,6 +121,20 @@ def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
|
|||
return errors
|
||||
|
||||
|
||||
def _patch_local_providers(recipe: dict[str, Any]) -> None:
|
||||
"""Strip is_local and fill a dummy endpoint so validation doesn't choke.
|
||||
|
||||
Uses a strict `is True` check to match _inject_local_providers in
|
||||
jobs.py - malformed payloads with truthy but non-boolean is_local
|
||||
values should not be treated as local.
|
||||
"""
|
||||
for provider in recipe.get("model_providers", []):
|
||||
if not isinstance(provider, dict):
|
||||
continue
|
||||
if provider.pop("is_local", None) is True:
|
||||
provider["endpoint"] = "http://127.0.0.1"
|
||||
|
||||
|
||||
@router.post("/validate", response_model = ValidateResponse)
|
||||
def validate(payload: RecipePayload) -> ValidateResponse:
|
||||
recipe = payload.recipe
|
||||
|
|
@ -77,6 +144,40 @@ def validate(payload: RecipePayload) -> ValidateResponse:
|
|||
errors = [ValidateError(message = "Recipe must include columns.")],
|
||||
)
|
||||
|
||||
_patch_local_providers(recipe)
|
||||
|
||||
github_source = _github_seed_source(recipe)
|
||||
if github_source is not None:
|
||||
static_errors = _validate_github_seed_static(github_source)
|
||||
if static_errors:
|
||||
return ValidateResponse(valid = False, errors = static_errors)
|
||||
try:
|
||||
build_config_builder(recipe)
|
||||
except ModuleNotFoundError as exc:
|
||||
# data_designer is an optional runtime dep. Static validation
|
||||
# already passed; live access + full config validation are
|
||||
# deferred to run start (per _GITHUB_VALIDATE_NOTE), so a missing
|
||||
# optional import at validate time should not block the recipe.
|
||||
# Restrict the bypass to the data_designer module specifically so
|
||||
# other ImportErrors (e.g. broken internal imports or missing
|
||||
# transitive deps after a package upgrade) still surface as
|
||||
# validation failures instead of being silently swallowed.
|
||||
if not (exc.name or "").startswith("data_designer"):
|
||||
raise
|
||||
logger.debug(
|
||||
"data_designer not installed; deferring full config "
|
||||
"validation to run start",
|
||||
missing_module = exc.name,
|
||||
)
|
||||
except Exception as exc:
|
||||
detail = str(exc).strip() or "Validation failed."
|
||||
return ValidateResponse(
|
||||
valid = False,
|
||||
errors = [ValidateError(message = detail)],
|
||||
raw_detail = detail,
|
||||
)
|
||||
return ValidateResponse(valid = True, raw_detail = _GITHUB_VALIDATE_NOTE)
|
||||
|
||||
try:
|
||||
validate_recipe(recipe)
|
||||
except RuntimeError as exc:
|
||||
|
|
|
|||
|
|
@ -11,10 +11,55 @@ import json
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
|
||||
import re as _re
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
||||
_VALID_REPO_ID = _re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
|
||||
|
||||
|
||||
def _is_valid_repo_id(repo_id: str) -> bool:
|
||||
return bool(_VALID_REPO_ID.fullmatch(repo_id))
|
||||
|
||||
|
||||
_dataset_size_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
def _get_dataset_size_cached(repo_id: str) -> int:
|
||||
if repo_id in _dataset_size_cache:
|
||||
return _dataset_size_cache[repo_id]
|
||||
try:
|
||||
from huggingface_hub import dataset_info as hf_dataset_info
|
||||
|
||||
info = hf_dataset_info(repo_id, token = None, files_metadata = True)
|
||||
total = sum(s.size for s in info.siblings if getattr(s, "size", None))
|
||||
_dataset_size_cache[repo_id] = total
|
||||
return total
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]:
|
||||
"""Pick the most useful on-disk path for a HF cache repo dir.
|
||||
|
||||
Mirrors the helper in routes/models.py: prefer the most-recent
|
||||
snapshot dir, fall back to the cache repo root, return resolved
|
||||
realpath. Duplicated here to keep routes/datasets.py self-contained.
|
||||
"""
|
||||
try:
|
||||
snapshots_dir = repo_dir / "snapshots"
|
||||
if snapshots_dir.is_dir():
|
||||
snaps = [s for s in snapshots_dir.iterdir() if s.is_dir()]
|
||||
if snaps:
|
||||
latest = max(snaps, key = lambda s: s.stat().st_mtime)
|
||||
return str(latest.resolve())
|
||||
return str(repo_dir.resolve())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# Add backend directory to path
|
||||
backend_path = Path(__file__).parent.parent.parent
|
||||
if str(backend_path) not in sys.path:
|
||||
|
|
@ -308,6 +353,89 @@ def list_local_datasets(
|
|||
return LocalDatasetsResponse(datasets = _build_local_dataset_items())
|
||||
|
||||
|
||||
@router.get("/download-progress")
|
||||
async def get_dataset_download_progress(
|
||||
repo_id: str = Query(
|
||||
..., description = "HuggingFace dataset repo ID, e.g. 'unsloth/LaTeX_OCR'"
|
||||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""Return download progress for a HuggingFace dataset repo.
|
||||
|
||||
Mirrors ``GET /api/models/download-progress`` but scans the
|
||||
``datasets--owner--name`` cache directory under HF_HUB_CACHE.
|
||||
Modern ``datasets``/``huggingface_hub`` caches both raw model and
|
||||
raw dataset blobs in HF_HUB_CACHE; the ``datasets`` library writes
|
||||
its processed Arrow shards elsewhere, but the in-progress *download*
|
||||
bytes are observable here. Returns ``cache_path`` so the UI can
|
||||
show users where the dataset blobs landed on disk.
|
||||
"""
|
||||
_empty = {
|
||||
"downloaded_bytes": 0,
|
||||
"expected_bytes": 0,
|
||||
"progress": 0,
|
||||
"cache_path": None,
|
||||
}
|
||||
try:
|
||||
if not _is_valid_repo_id(repo_id):
|
||||
return _empty
|
||||
|
||||
from huggingface_hub import constants as hf_constants
|
||||
|
||||
cache_dir = Path(hf_constants.HF_HUB_CACHE)
|
||||
target = f"datasets--{repo_id.replace('/', '--')}".lower()
|
||||
completed_bytes = 0
|
||||
in_progress_bytes = 0
|
||||
cache_path: Optional[str] = None
|
||||
|
||||
if cache_dir.is_dir():
|
||||
for entry in cache_dir.iterdir():
|
||||
if entry.name.lower() != target:
|
||||
continue
|
||||
cache_path = _resolve_hf_cache_realpath(entry)
|
||||
blobs_dir = entry / "blobs"
|
||||
if not blobs_dir.is_dir():
|
||||
break
|
||||
for f in blobs_dir.iterdir():
|
||||
if not f.is_file():
|
||||
continue
|
||||
if f.name.endswith(".incomplete"):
|
||||
in_progress_bytes += f.stat().st_size
|
||||
else:
|
||||
completed_bytes += f.stat().st_size
|
||||
break
|
||||
|
||||
downloaded_bytes = completed_bytes + in_progress_bytes
|
||||
if downloaded_bytes == 0:
|
||||
return {**_empty, "cache_path": cache_path}
|
||||
|
||||
expected_bytes = _get_dataset_size_cached(repo_id)
|
||||
if expected_bytes <= 0:
|
||||
return {
|
||||
"downloaded_bytes": downloaded_bytes,
|
||||
"expected_bytes": 0,
|
||||
"progress": 0,
|
||||
"cache_path": cache_path,
|
||||
}
|
||||
|
||||
# Same 95% completion threshold as the model endpoint -- HF blob
|
||||
# dedup makes completed_bytes drift slightly under expected_bytes,
|
||||
# and inter-file gaps would otherwise look like "done".
|
||||
if completed_bytes >= expected_bytes * 0.95:
|
||||
progress = 1.0
|
||||
else:
|
||||
progress = min(downloaded_bytes / expected_bytes, 0.99)
|
||||
return {
|
||||
"downloaded_bytes": downloaded_bytes,
|
||||
"expected_bytes": expected_bytes,
|
||||
"progress": round(progress, 3),
|
||||
"cache_path": cache_path,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking dataset download progress for {repo_id}: {e}")
|
||||
return _empty
|
||||
|
||||
|
||||
@router.post("/check-format", response_model = CheckFormatResponse)
|
||||
def check_format(
|
||||
request: CheckFormatRequest,
|
||||
|
|
|
|||
|
|
@ -5,9 +5,15 @@
|
|||
Export API routes: checkpoint discovery and model export operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
||||
|
|
@ -97,7 +103,11 @@ async def load_checkpoint(
|
|||
logger.warning("Could not stop training: %s", e)
|
||||
|
||||
backend = get_export_backend()
|
||||
success, message = backend.load_checkpoint(
|
||||
# load_checkpoint spawns and waits on a subprocess and can take
|
||||
# minutes. Run it in a worker thread so the event loop stays
|
||||
# free to serve the live log SSE stream concurrently.
|
||||
success, message = await asyncio.to_thread(
|
||||
backend.load_checkpoint,
|
||||
checkpoint_path = request.checkpoint_path,
|
||||
max_seq_length = request.max_seq_length,
|
||||
load_in_4bit = request.load_in_4bit,
|
||||
|
|
@ -129,7 +139,7 @@ async def cleanup_export_memory(
|
|||
"""
|
||||
try:
|
||||
backend = get_export_backend()
|
||||
success = backend.cleanup_memory()
|
||||
success = await asyncio.to_thread(backend.cleanup_memory)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
|
|
@ -173,6 +183,17 @@ async def get_export_status(
|
|||
)
|
||||
|
||||
|
||||
def _export_details(output_path: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""Wrap the resolved on-disk export path into the details dict the
|
||||
frontend reads to populate the Export Complete screen. Returns None
|
||||
when the export had no local component (Hub-only push) so the
|
||||
Pydantic field stays absent rather than ``{"output_path": null}``.
|
||||
"""
|
||||
if not output_path:
|
||||
return None
|
||||
return {"output_path": output_path}
|
||||
|
||||
|
||||
@router.post("/export/merged", response_model = ExportOperationResponse)
|
||||
async def export_merged_model(
|
||||
request: ExportMergedModelRequest,
|
||||
|
|
@ -185,7 +206,8 @@ async def export_merged_model(
|
|||
"""
|
||||
try:
|
||||
backend = get_export_backend()
|
||||
success, message = backend.export_merged_model(
|
||||
success, message, output_path = await asyncio.to_thread(
|
||||
backend.export_merged_model,
|
||||
save_directory = request.save_directory,
|
||||
format_type = request.format_type,
|
||||
push_to_hub = request.push_to_hub,
|
||||
|
|
@ -197,7 +219,11 @@ async def export_merged_model(
|
|||
if not success:
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success = True, message = message)
|
||||
return ExportOperationResponse(
|
||||
success = True,
|
||||
message = message,
|
||||
details = _export_details(output_path),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -220,7 +246,8 @@ async def export_base_model(
|
|||
"""
|
||||
try:
|
||||
backend = get_export_backend()
|
||||
success, message = backend.export_base_model(
|
||||
success, message, output_path = await asyncio.to_thread(
|
||||
backend.export_base_model,
|
||||
save_directory = request.save_directory,
|
||||
push_to_hub = request.push_to_hub,
|
||||
repo_id = request.repo_id,
|
||||
|
|
@ -232,7 +259,11 @@ async def export_base_model(
|
|||
if not success:
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success = True, message = message)
|
||||
return ExportOperationResponse(
|
||||
success = True,
|
||||
message = message,
|
||||
details = _export_details(output_path),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -255,7 +286,8 @@ async def export_gguf(
|
|||
"""
|
||||
try:
|
||||
backend = get_export_backend()
|
||||
success, message = backend.export_gguf(
|
||||
success, message, output_path = await asyncio.to_thread(
|
||||
backend.export_gguf,
|
||||
save_directory = request.save_directory,
|
||||
quantization_method = request.quantization_method,
|
||||
push_to_hub = request.push_to_hub,
|
||||
|
|
@ -266,7 +298,11 @@ async def export_gguf(
|
|||
if not success:
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success = True, message = message)
|
||||
return ExportOperationResponse(
|
||||
success = True,
|
||||
message = message,
|
||||
details = _export_details(output_path),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -289,7 +325,8 @@ async def export_lora_adapter(
|
|||
"""
|
||||
try:
|
||||
backend = get_export_backend()
|
||||
success, message = backend.export_lora_adapter(
|
||||
success, message, output_path = await asyncio.to_thread(
|
||||
backend.export_lora_adapter,
|
||||
save_directory = request.save_directory,
|
||||
push_to_hub = request.push_to_hub,
|
||||
repo_id = request.repo_id,
|
||||
|
|
@ -300,7 +337,11 @@ async def export_lora_adapter(
|
|||
if not success:
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success = True, message = message)
|
||||
return ExportOperationResponse(
|
||||
success = True,
|
||||
message = message,
|
||||
details = _export_details(output_path),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -309,3 +350,155 @@ async def export_lora_adapter(
|
|||
status_code = 500,
|
||||
detail = f"Failed to export LoRA adapter: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Live export log stream (Server-Sent Events)
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# The export worker subprocess redirects its stdout/stderr into a pipe
|
||||
# that a reader thread forwards to the orchestrator as log entries (see
|
||||
# core/export/worker.py::_setup_log_capture and
|
||||
# core/export/orchestrator.py::_append_log). This endpoint streams
|
||||
# those entries to the browser so the export dialog can show a live
|
||||
# terminal-style output panel while load_checkpoint / export_merged /
|
||||
# export_gguf / export_lora / export_base run.
|
||||
#
|
||||
# Shape follows the training progress SSE endpoint
|
||||
# (routes/training.py::stream_training_progress): each event carries
|
||||
# `id`, `event`, and `data` fields, the stream starts with a `retry:`
|
||||
# directive, and `Last-Event-ID` is honored on reconnect.
|
||||
|
||||
|
||||
def _format_sse(data: str, event: str, event_id: Optional[int] = None) -> str:
|
||||
"""Format a single SSE message with id/event/data fields."""
|
||||
lines = []
|
||||
if event_id is not None:
|
||||
lines.append(f"id: {event_id}")
|
||||
lines.append(f"event: {event}")
|
||||
lines.append(f"data: {data}")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.get("/logs/stream")
|
||||
async def stream_export_logs(
|
||||
request: Request,
|
||||
since: Optional[int] = Query(
|
||||
None,
|
||||
description = "Return log entries with seq strictly greater than this cursor.",
|
||||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
Stream live stdout/stderr output from the export worker subprocess
|
||||
as Server-Sent Events.
|
||||
|
||||
Events:
|
||||
- `log` : a single log line (data: {"stream","line","ts"})
|
||||
- `heartbeat`: periodic keepalive when no new lines are available
|
||||
- `complete` : emitted once the export worker is idle and no new
|
||||
lines arrived for ~1 second. Clients should close.
|
||||
- `error` : unrecoverable server-side error
|
||||
|
||||
The `id:` field on each event is the log entry's monotonic seq
|
||||
number so the browser can resume via `Last-Event-ID` on reconnect.
|
||||
"""
|
||||
backend = get_export_backend()
|
||||
|
||||
# Determine starting cursor. Explicit `since` wins, then
|
||||
# Last-Event-ID header on reconnect, otherwise start from the
|
||||
# run-start snapshot captured by clear_logs() so the client sees
|
||||
# every line emitted since the current run began -- even if the
|
||||
# SSE connection opened after the POST that kicked off the export.
|
||||
# Using get_current_log_seq() here would lose the early bootstrap
|
||||
# lines that arrive in the gap between POST and SSE connect.
|
||||
last_event_id = request.headers.get("last-event-id")
|
||||
if since is None and last_event_id is not None:
|
||||
try:
|
||||
since = int(last_event_id)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if since is None:
|
||||
cursor = backend.get_run_start_seq()
|
||||
else:
|
||||
cursor = max(0, int(since))
|
||||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
nonlocal cursor
|
||||
# Tell the browser to reconnect after 3 seconds if the
|
||||
# connection drops mid-export.
|
||||
yield "retry: 3000\n\n"
|
||||
|
||||
last_yield = time.monotonic()
|
||||
idle_since: Optional[float] = None
|
||||
try:
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
|
||||
entries, new_cursor = backend.get_logs_since(cursor)
|
||||
if entries:
|
||||
for entry in entries:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"stream": entry.get("stream", "stdout"),
|
||||
"line": entry.get("line", ""),
|
||||
"ts": entry.get("ts"),
|
||||
}
|
||||
)
|
||||
yield _format_sse(
|
||||
payload,
|
||||
event = "log",
|
||||
event_id = int(entry.get("seq", 0)),
|
||||
)
|
||||
cursor = new_cursor
|
||||
last_yield = time.monotonic()
|
||||
idle_since = None
|
||||
else:
|
||||
now = time.monotonic()
|
||||
if now - last_yield > 10.0:
|
||||
yield _format_sse("{}", event = "heartbeat")
|
||||
last_yield = now
|
||||
if not backend.is_export_active():
|
||||
# Give the reader thread a moment to drain any
|
||||
# trailing lines the worker process printed
|
||||
# just before signalling done.
|
||||
if idle_since is None:
|
||||
idle_since = now
|
||||
elif now - idle_since > 1.0:
|
||||
yield _format_sse(
|
||||
"{}",
|
||||
event = "complete",
|
||||
event_id = cursor,
|
||||
)
|
||||
return
|
||||
else:
|
||||
idle_since = None
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
# Client disconnected mid-yield. Don't re-raise, just end
|
||||
# the generator cleanly so StreamingResponse finalizes.
|
||||
return
|
||||
except Exception as exc:
|
||||
logger.error("Export log stream failed: %s", exc, exc_info = True)
|
||||
try:
|
||||
yield _format_sse(
|
||||
json.dumps({"error": str(exc)}),
|
||||
event = "error",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -25,6 +25,12 @@ if str(backend_path) not in sys.path:
|
|||
# Import backend functions
|
||||
try:
|
||||
from core.training import get_training_backend
|
||||
from core.training.resume import (
|
||||
can_resume_run,
|
||||
get_resume_checkpoint_path,
|
||||
normalize_resume_output_dir,
|
||||
)
|
||||
from storage.studio_db import get_resumable_run_by_output_dir
|
||||
from utils.models.model_config import load_model_defaults
|
||||
from utils.paths import resolve_dataset_path
|
||||
except ImportError:
|
||||
|
|
@ -33,6 +39,12 @@ except ImportError:
|
|||
if str(parent_backend) not in sys.path:
|
||||
sys.path.insert(0, str(parent_backend))
|
||||
from core.training import get_training_backend
|
||||
from core.training.resume import (
|
||||
can_resume_run,
|
||||
get_resume_checkpoint_path,
|
||||
normalize_resume_output_dir,
|
||||
)
|
||||
from storage.studio_db import get_resumable_run_by_output_dir
|
||||
from utils.models.model_config import load_model_defaults
|
||||
from utils.paths import resolve_dataset_path
|
||||
|
||||
|
|
@ -152,6 +164,28 @@ async def start_training(
|
|||
request.local_eval_datasets = _validate_local_dataset_paths(
|
||||
request.local_eval_datasets, "Local eval dataset"
|
||||
)
|
||||
resume_output_dir: Optional[str] = None
|
||||
if request.resume_from_checkpoint:
|
||||
try:
|
||||
resume_output_dir = normalize_resume_output_dir(
|
||||
request.resume_from_checkpoint
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code = 400, detail = str(e))
|
||||
|
||||
resume_run = get_resumable_run_by_output_dir(resume_output_dir)
|
||||
if not resume_run or not can_resume_run(resume_run):
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Resume checkpoint must belong to a stopped run with saved trainer state.",
|
||||
)
|
||||
resume_checkpoint = get_resume_checkpoint_path(resume_output_dir)
|
||||
if not resume_checkpoint:
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Resume checkpoint must include saved trainer state.",
|
||||
)
|
||||
request.resume_from_checkpoint = resume_checkpoint
|
||||
|
||||
# Convert request to kwargs for backend
|
||||
training_kwargs = {
|
||||
|
|
@ -209,6 +243,8 @@ async def start_training(
|
|||
"wandb_project": request.wandb_project or "",
|
||||
"enable_tensorboard": request.enable_tensorboard,
|
||||
"tensorboard_dir": request.tensorboard_dir or "",
|
||||
"output_dir": resume_output_dir,
|
||||
"resume_from_checkpoint": request.resume_from_checkpoint,
|
||||
"trust_remote_code": request.trust_remote_code,
|
||||
"gpu_ids": request.gpu_ids,
|
||||
}
|
||||
|
|
@ -437,6 +473,9 @@ async def get_training_status(
|
|||
"loss": getattr(progress, "loss", None),
|
||||
"learning_rate": getattr(progress, "learning_rate", None),
|
||||
}
|
||||
output_dir = getattr(backend, "_output_dir", None)
|
||||
if output_dir:
|
||||
details["output_dir"] = output_dir
|
||||
|
||||
# Build metric history for chart recovery after SSE reconnection
|
||||
metric_history = None
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
|||
from loggers import get_logger
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from core.training.resume import can_resume_run
|
||||
from models import (
|
||||
TrainingRunDeleteResponse,
|
||||
TrainingRunDetailResponse,
|
||||
|
|
@ -34,7 +35,10 @@ async def list_training_runs(
|
|||
"""List training runs, newest first."""
|
||||
result = list_runs(limit = limit, offset = offset)
|
||||
return TrainingRunListResponse(
|
||||
runs = [TrainingRunSummary(**r) for r in result["runs"]],
|
||||
runs = [
|
||||
TrainingRunSummary(**{**r, "can_resume": can_resume_run(r)})
|
||||
for r in result["runs"]
|
||||
],
|
||||
total = result["total"],
|
||||
)
|
||||
|
||||
|
|
@ -58,7 +62,12 @@ async def get_training_run_detail(
|
|||
metrics_data = get_run_metrics(run_id)
|
||||
|
||||
return TrainingRunDetailResponse(
|
||||
run = TrainingRunSummary(**{k: v for k, v in run.items() if k != "config_json"}),
|
||||
run = TrainingRunSummary(
|
||||
**{
|
||||
**{k: v for k, v in run.items() if k != "config_json"},
|
||||
"can_resume": can_resume_run(run),
|
||||
}
|
||||
),
|
||||
config = config,
|
||||
metrics = TrainingRunMetrics(**metrics_data),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -244,10 +244,12 @@ _shutdown_event = None
|
|||
|
||||
|
||||
def run_server(
|
||||
host: str = "0.0.0.0",
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8888,
|
||||
frontend_path: Path = Path(__file__).resolve().parent.parent / "frontend" / "dist",
|
||||
silent: bool = False,
|
||||
api_only: bool = False,
|
||||
llama_parallel_slots: int = 1,
|
||||
):
|
||||
"""
|
||||
Start the FastAPI server.
|
||||
|
|
@ -257,6 +259,8 @@ def run_server(
|
|||
port: Port to bind to (auto-increments if in use)
|
||||
frontend_path: Path to frontend build directory (optional)
|
||||
silent: Suppress startup messages
|
||||
api_only: Run API server only, no frontend serving (for Tauri desktop app)
|
||||
llama_parallel_slots: Number of parallel slots for llama-server
|
||||
|
||||
Note:
|
||||
Signal handlers are NOT registered here so that embedders
|
||||
|
|
@ -273,6 +277,10 @@ def run_server(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Set env var BEFORE importing main so CORS middleware picks it up
|
||||
if api_only:
|
||||
os.environ["UNSLOTH_API_ONLY"] = "1"
|
||||
|
||||
import nest_asyncio
|
||||
|
||||
nest_asyncio.apply()
|
||||
|
|
@ -308,8 +316,12 @@ def run_server(
|
|||
print("=" * 50)
|
||||
print("")
|
||||
|
||||
# Setup frontend if path provided
|
||||
if frontend_path:
|
||||
# Output port for Tauri to parse when in api-only mode
|
||||
if api_only:
|
||||
print(f"TAURI_PORT={port}", flush = True)
|
||||
|
||||
# Setup frontend if path provided (skip in api-only mode)
|
||||
if frontend_path and not api_only:
|
||||
if setup_frontend(app, frontend_path):
|
||||
if not silent:
|
||||
print(f"[OK] Frontend loaded from {frontend_path}")
|
||||
|
|
@ -324,6 +336,15 @@ def run_server(
|
|||
_server = uvicorn.Server(config)
|
||||
_shutdown_event = Event()
|
||||
|
||||
# Expose the actual bound port so request-handling code can build
|
||||
# loopback URLs that point at the real backend, not whatever port a
|
||||
# reverse proxy or tunnel exposed in the request URL. Only publish
|
||||
# an explicit value when we know the concrete port; for ephemeral
|
||||
# binds (port==0) leave it unset and let request handlers fall back
|
||||
# to the ASGI request scope or request.base_url.
|
||||
app.state.server_port = port if port and port > 0 else None
|
||||
app.state.llama_parallel_slots = llama_parallel_slots
|
||||
|
||||
# Run server in a daemon thread
|
||||
def _run():
|
||||
asyncio.run(_server.serve())
|
||||
|
|
@ -371,7 +392,11 @@ if __name__ == "__main__":
|
|||
pass
|
||||
|
||||
parser = argparse.ArgumentParser(description = "Run Unsloth UI Backend server")
|
||||
parser.add_argument("--host", default = "0.0.0.0", help = "Host to bind to")
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default = "127.0.0.1",
|
||||
help = "Host to bind to (default: 127.0.0.1; use 0.0.0.0 for network/cloud access)",
|
||||
)
|
||||
parser.add_argument("--port", type = int, default = 8888, help = "Port to bind to")
|
||||
parser.add_argument(
|
||||
"--frontend",
|
||||
|
|
@ -380,10 +405,17 @@ if __name__ == "__main__":
|
|||
help = "Path to frontend build",
|
||||
)
|
||||
parser.add_argument("--silent", action = "store_true", help = "Suppress output")
|
||||
parser.add_argument(
|
||||
"--api-only",
|
||||
action = "store_true",
|
||||
help = "API server only, no frontend (for Tauri)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
kwargs = dict(host = args.host, port = args.port, silent = args.silent)
|
||||
kwargs = dict(
|
||||
host = args.host, port = args.port, silent = args.silent, api_only = args.api_only
|
||||
)
|
||||
if args.frontend is not None:
|
||||
kwargs["frontend_path"] = Path(args.frontend)
|
||||
|
||||
|
|
|
|||
33
studio/backend/state/tool_policy.py
Normal file
33
studio/backend/state/tool_policy.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
"""Process-level server-side tool policy.
|
||||
|
||||
Set by `unsloth run` at startup; consulted by the inference route gates.
|
||||
|
||||
None -> no CLI override (default). Per-request `enable_tools` is honored.
|
||||
True -> CLI forced tools on for every request.
|
||||
False -> CLI forced tools off for every request.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
_tool_policy: Optional[bool] = None
|
||||
|
||||
|
||||
def get_tool_policy() -> Optional[bool]:
|
||||
return _tool_policy
|
||||
|
||||
|
||||
def set_tool_policy(value: Optional[bool]) -> None:
|
||||
if value is not None and not isinstance(value, bool):
|
||||
raise TypeError(
|
||||
f"tool_policy must be Optional[bool], got {type(value).__name__}"
|
||||
)
|
||||
global _tool_policy
|
||||
_tool_policy = value
|
||||
|
||||
|
||||
def reset_tool_policy() -> None:
|
||||
global _tool_policy
|
||||
_tool_policy = None
|
||||
|
|
@ -267,10 +267,23 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict:
|
|||
total = conn.execute("SELECT COUNT(*) FROM training_runs").fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, status, model_name, dataset_name, started_at, ended_at,
|
||||
total_steps, final_step, final_loss, output_dir,
|
||||
duration_seconds, error_message, loss_sparkline
|
||||
FROM training_runs
|
||||
SELECT r.id, r.status, r.model_name, r.dataset_name, r.started_at,
|
||||
r.ended_at, r.total_steps, r.final_step, r.final_loss,
|
||||
r.output_dir, r.duration_seconds, r.error_message,
|
||||
r.loss_sparkline,
|
||||
CASE
|
||||
WHEN r.status = 'stopped'
|
||||
AND r.output_dir IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM training_runs newer
|
||||
WHERE newer.output_dir = r.output_dir
|
||||
AND newer.status IN ('stopped', 'completed')
|
||||
AND newer.started_at > r.started_at
|
||||
)
|
||||
THEN 1 ELSE 0
|
||||
END AS resumed_later
|
||||
FROM training_runs r
|
||||
ORDER BY started_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
|
|
@ -297,7 +310,26 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict:
|
|||
def get_run(id: str) -> Optional[dict]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM training_runs WHERE id = ?", (id,)).fetchone()
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT r.*,
|
||||
CASE
|
||||
WHEN r.status = 'stopped'
|
||||
AND r.output_dir IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM training_runs newer
|
||||
WHERE newer.output_dir = r.output_dir
|
||||
AND newer.status IN ('stopped', 'completed')
|
||||
AND newer.started_at > r.started_at
|
||||
)
|
||||
THEN 1 ELSE 0
|
||||
END AS resumed_later
|
||||
FROM training_runs r
|
||||
WHERE r.id = ?
|
||||
""",
|
||||
(id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
run = dict(row)
|
||||
|
|
@ -313,6 +345,45 @@ def get_run(id: str) -> Optional[dict]:
|
|||
conn.close()
|
||||
|
||||
|
||||
def get_resumable_run_by_output_dir(output_dir: str) -> Optional[dict]:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT r.*,
|
||||
0 AS resumed_later
|
||||
FROM training_runs r
|
||||
WHERE r.output_dir = ?
|
||||
AND r.status = 'stopped'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM training_runs newer
|
||||
WHERE newer.output_dir = r.output_dir
|
||||
AND newer.status IN ('stopped', 'completed')
|
||||
AND newer.started_at > r.started_at
|
||||
)
|
||||
ORDER BY r.started_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(output_dir,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
run = dict(row)
|
||||
sparkline = run.get("loss_sparkline")
|
||||
if sparkline:
|
||||
try:
|
||||
run["loss_sparkline"] = json.loads(sparkline)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.debug(
|
||||
"Failed to parse loss_sparkline for output_dir %s", output_dir
|
||||
)
|
||||
run["loss_sparkline"] = None
|
||||
return run
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_run_metrics(id: str) -> dict:
|
||||
"""Return metric arrays for a run, using paired step arrays per metric."""
|
||||
conn = get_connection()
|
||||
|
|
|
|||
|
|
@ -3,14 +3,136 @@
|
|||
|
||||
"""
|
||||
Shared pytest configuration for the backend test suite.
|
||||
Ensures that the backend root is on sys.path so that
|
||||
`import utils.utils` (and similar flat imports) resolve correctly.
|
||||
|
||||
Responsibilities:
|
||||
1. Put the backend root on sys.path so `from models.inference import ...`
|
||||
(and similar flat imports) resolve in test modules — mirrors how the
|
||||
app itself is launched.
|
||||
2. Provide a hybrid ``studio_server`` session fixture for end-to-end tests
|
||||
(see ``test_studio_api.py``). The fixture supports two invocation modes:
|
||||
|
||||
a. **External server.** If ``UNSLOTH_E2E_BASE_URL`` is set, tests point
|
||||
at an already-running Studio instance. ``UNSLOTH_E2E_API_KEY`` must
|
||||
also be set. This is the fast-iteration mode: start the server once
|
||||
with ``unsloth studio run ...``, then run pytest against it many
|
||||
times with no per-run GGUF load cost.
|
||||
|
||||
b. **Fixture-managed server.** Otherwise, the fixture launches a fresh
|
||||
server via ``_start_server`` and tears it down at session end. This
|
||||
is the one-shot mode for CI or a clean-slate verification run.
|
||||
|
||||
The model / variant for mode (b) come from ``--unsloth-model`` /
|
||||
``--unsloth-gguf-variant`` pytest options, then ``UNSLOTH_E2E_MODEL`` /
|
||||
``UNSLOTH_E2E_VARIANT`` env vars, then the defaults in
|
||||
``test_studio_api.py``.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add backend root to sys.path (mirrors how the app itself is launched)
|
||||
_backend_root = Path(__file__).resolve().parent.parent
|
||||
if str(_backend_root) not in sys.path:
|
||||
sys.path.insert(0, str(_backend_root))
|
||||
|
||||
|
||||
# ── Pytest CLI options ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
group = parser.getgroup(
|
||||
"unsloth-e2e",
|
||||
"Unsloth Studio end-to-end test options",
|
||||
)
|
||||
group.addoption(
|
||||
"--unsloth-model",
|
||||
action = "store",
|
||||
default = None,
|
||||
help = (
|
||||
"GGUF model id used when starting a server for e2e tests. "
|
||||
"Ignored if UNSLOTH_E2E_BASE_URL is set. Overrides "
|
||||
"UNSLOTH_E2E_MODEL env var. Defaults to test_studio_api.py's "
|
||||
"DEFAULT_MODEL."
|
||||
),
|
||||
)
|
||||
group.addoption(
|
||||
"--unsloth-gguf-variant",
|
||||
action = "store",
|
||||
default = None,
|
||||
help = (
|
||||
"GGUF variant used when starting a server for e2e tests. "
|
||||
"Ignored if UNSLOTH_E2E_BASE_URL is set. Overrides "
|
||||
"UNSLOTH_E2E_VARIANT env var. Defaults to test_studio_api.py's "
|
||||
"DEFAULT_VARIANT."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ── E2E server fixtures ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope = "session")
|
||||
def studio_server(request):
|
||||
"""Yield ``(base_url, api_key)`` for e2e tests.
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. If ``UNSLOTH_E2E_BASE_URL`` is set → point at that server,
|
||||
require ``UNSLOTH_E2E_API_KEY`` alongside (skip if missing).
|
||||
2. Otherwise → start a fresh ``unsloth studio run`` subprocess via
|
||||
the existing ``_start_server`` helper in ``test_studio_api.py``
|
||||
and tear it down on session teardown.
|
||||
|
||||
Session-scoped so the expensive GGUF load happens at most once per
|
||||
pytest invocation. Lazily instantiated — tests that don't request
|
||||
the fixture (e.g. the unit tests in ``test_anthropic_messages.py``
|
||||
or ``test_help_output``) do not trigger server startup.
|
||||
"""
|
||||
external_url = os.environ.get("UNSLOTH_E2E_BASE_URL")
|
||||
if external_url:
|
||||
api_key = os.environ.get("UNSLOTH_E2E_API_KEY")
|
||||
if not api_key:
|
||||
pytest.skip(
|
||||
"UNSLOTH_E2E_BASE_URL is set but UNSLOTH_E2E_API_KEY is "
|
||||
"missing — tests that require auth cannot run against an "
|
||||
"external server without it.",
|
||||
)
|
||||
yield external_url, api_key
|
||||
return
|
||||
|
||||
# Lazy import: pytest has already loaded test_studio_api into
|
||||
# sys.modules by the time any test requests this fixture, so this
|
||||
# is a cache hit, not a re-execution.
|
||||
import test_studio_api as _e2e
|
||||
|
||||
model = (
|
||||
request.config.getoption("--unsloth-model")
|
||||
or os.environ.get("UNSLOTH_E2E_MODEL")
|
||||
or _e2e.DEFAULT_MODEL
|
||||
)
|
||||
variant = (
|
||||
request.config.getoption("--unsloth-gguf-variant")
|
||||
or os.environ.get("UNSLOTH_E2E_VARIANT")
|
||||
or _e2e.DEFAULT_VARIANT
|
||||
)
|
||||
|
||||
proc, api_key = _e2e._start_server(model, variant)
|
||||
try:
|
||||
yield f"http://{_e2e.HOST}:{_e2e.PORT}", api_key
|
||||
finally:
|
||||
_e2e._kill_server(proc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_url(studio_server):
|
||||
"""Base URL for the e2e Studio server (from ``studio_server``)."""
|
||||
return studio_server[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_key(studio_server):
|
||||
"""API key for the e2e Studio server (from ``studio_server``)."""
|
||||
return studio_server[1]
|
||||
|
|
|
|||
1013
studio/backend/tests/test_anthropic_messages.py
Normal file
1013
studio/backend/tests/test_anthropic_messages.py
Normal file
File diff suppressed because it is too large
Load diff
86
studio/backend/tests/test_browse_folders_route.py
Normal file
86
studio/backend/tests/test_browse_folders_route.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Keep this test runnable in lightweight environments where optional logging
|
||||
# deps are not installed.
|
||||
if "structlog" not in sys.modules:
|
||||
|
||||
class _DummyLogger:
|
||||
def __getattr__(self, _name):
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
sys.modules["structlog"] = types.SimpleNamespace(
|
||||
BoundLogger = _DummyLogger,
|
||||
get_logger = lambda *args, **kwargs: _DummyLogger(),
|
||||
)
|
||||
|
||||
import routes.models as models_route
|
||||
|
||||
|
||||
def test_resolve_browse_target_returns_allowed_directory(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
target = allowed / "models" / "nested"
|
||||
target.mkdir(parents = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(str(target), [allowed])
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_browse_target_rejects_outside_allowlist(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
disallowed = tmp_path / "disallowed"
|
||||
allowed.mkdir()
|
||||
disallowed.mkdir()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
models_route._resolve_browse_target(str(disallowed), [allowed])
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_resolve_browse_target_rejects_file_path(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
allowed.mkdir()
|
||||
model_file = allowed / "model.gguf"
|
||||
model_file.write_text("gguf")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
models_route._resolve_browse_target(str(model_file), [allowed])
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_resolve_browse_target_allows_symlink_into_other_allowed_root(tmp_path):
|
||||
home_root = tmp_path / "home"
|
||||
scan_root = tmp_path / "scan"
|
||||
target = scan_root / "nested"
|
||||
home_root.mkdir()
|
||||
target.mkdir(parents = True)
|
||||
(home_root / "scan-link").symlink_to(scan_root, target_is_directory = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(
|
||||
str(home_root / "scan-link" / "nested"),
|
||||
[home_root, scan_root],
|
||||
)
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.altsep is not None, reason = "POSIX-only path semantics")
|
||||
def test_resolve_browse_target_allows_backslash_in_posix_segment(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
target = allowed / r"dir\name"
|
||||
target.mkdir(parents = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(str(target), [allowed])
|
||||
|
||||
assert resolved == target.resolve()
|
||||
120
studio/backend/tests/test_cache_case_resolution.py
Normal file
120
studio/backend/tests/test_cache_case_resolution.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# 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 pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
# Keep this test runnable in lightweight environments where optional logging
|
||||
# deps are not installed.
|
||||
if "structlog" not in sys.modules:
|
||||
|
||||
class _DummyLogger:
|
||||
def __getattr__(self, _name):
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
sys.modules["structlog"] = types.SimpleNamespace(
|
||||
BoundLogger = _DummyLogger,
|
||||
get_logger = lambda *args, **kwargs: _DummyLogger(),
|
||||
)
|
||||
|
||||
from utils.paths.path_utils import (
|
||||
resolve_cached_repo_id_case,
|
||||
get_cache_case_resolution_stats,
|
||||
reset_cache_case_resolution_state,
|
||||
)
|
||||
import utils.paths.path_utils as path_utils
|
||||
|
||||
|
||||
def _mk_cache_repo(cache_root: Path, repo_id: str) -> Path:
|
||||
repo_dir = cache_root / f"models--{repo_id.replace('/', '--')}"
|
||||
repo_dir.mkdir(parents = True, exist_ok = True)
|
||||
return repo_dir
|
||||
|
||||
|
||||
def test_resolve_cached_repo_id_case_exact_hit(tmp_path, monkeypatch):
|
||||
reset_cache_case_resolution_state()
|
||||
_mk_cache_repo(tmp_path, "Org/Model")
|
||||
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
|
||||
|
||||
resolved = resolve_cached_repo_id_case("Org/Model")
|
||||
|
||||
assert resolved == "Org/Model"
|
||||
stats = get_cache_case_resolution_stats()
|
||||
assert stats["calls"] == 1
|
||||
assert stats["exact_hits"] == 1
|
||||
assert stats["variant_hits"] == 0
|
||||
|
||||
|
||||
def test_resolve_cached_repo_id_case_variant_hit(tmp_path, monkeypatch):
|
||||
reset_cache_case_resolution_state()
|
||||
_mk_cache_repo(tmp_path, "Org/Model")
|
||||
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
|
||||
|
||||
resolved = resolve_cached_repo_id_case("org/model")
|
||||
|
||||
assert resolved == "Org/Model"
|
||||
stats = get_cache_case_resolution_stats()
|
||||
assert stats["variant_hits"] == 1
|
||||
assert stats["tie_breaks"] == 0
|
||||
|
||||
|
||||
def test_resolve_cached_repo_id_case_tie_break_deterministic(tmp_path, monkeypatch):
|
||||
reset_cache_case_resolution_state()
|
||||
_mk_cache_repo(tmp_path, "Org/Model")
|
||||
_mk_cache_repo(tmp_path, "org/model")
|
||||
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
|
||||
|
||||
resolved = resolve_cached_repo_id_case("oRg/mOdEl")
|
||||
|
||||
# Deterministic rule: lexical sort of candidate repo ids.
|
||||
assert resolved == "Org/Model"
|
||||
stats = get_cache_case_resolution_stats()
|
||||
assert stats["variant_hits"] == 1
|
||||
assert stats["tie_breaks"] == 1
|
||||
|
||||
|
||||
def test_resolve_cached_repo_id_case_no_cache_fallback(tmp_path, monkeypatch):
|
||||
reset_cache_case_resolution_state()
|
||||
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
|
||||
|
||||
resolved = resolve_cached_repo_id_case("Org/Missing")
|
||||
|
||||
assert resolved == "Org/Missing"
|
||||
stats = get_cache_case_resolution_stats()
|
||||
assert stats["fallbacks"] == 1
|
||||
assert stats["variant_hits"] == 0
|
||||
assert stats["exact_hits"] == 0
|
||||
|
||||
|
||||
def test_resolve_cached_repo_id_case_memoization(tmp_path, monkeypatch):
|
||||
reset_cache_case_resolution_state()
|
||||
_mk_cache_repo(tmp_path, "Org/Model")
|
||||
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
|
||||
|
||||
first = resolve_cached_repo_id_case("org/model")
|
||||
second = resolve_cached_repo_id_case("org/model")
|
||||
|
||||
assert first == "Org/Model"
|
||||
assert second == "Org/Model"
|
||||
stats = get_cache_case_resolution_stats()
|
||||
assert stats["calls"] == 2
|
||||
assert stats["variant_hits"] == 1
|
||||
assert stats["memo_hits"] == 1
|
||||
|
||||
|
||||
def test_resolve_cached_repo_id_case_late_cache_population(tmp_path, monkeypatch):
|
||||
"""Regression guard: memoized fallback should not hide a later cache variant."""
|
||||
reset_cache_case_resolution_state()
|
||||
monkeypatch.setattr(path_utils, "_hf_hub_cache_dir", lambda: tmp_path)
|
||||
|
||||
first = resolve_cached_repo_id_case("org/model")
|
||||
assert first == "org/model"
|
||||
|
||||
# Simulate cache being populated after first miss (e.g. another code path/download).
|
||||
_mk_cache_repo(tmp_path, "Org/Model")
|
||||
|
||||
second = resolve_cached_repo_id_case("org/model")
|
||||
|
||||
# Desired behavior: second lookup should pick up the now-existing variant.
|
||||
assert second == "Org/Model"
|
||||
398
studio/backend/tests/test_cached_gguf_routes.py
Normal file
398
studio/backend/tests/test_cached_gguf_routes.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Keep this test runnable in lightweight environments where optional logging
|
||||
# deps are not installed.
|
||||
if "structlog" not in sys.modules:
|
||||
|
||||
class _DummyLogger:
|
||||
def __getattr__(self, _name):
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
sys.modules["structlog"] = types.SimpleNamespace(
|
||||
BoundLogger = _DummyLogger,
|
||||
get_logger = lambda *args, **kwargs: _DummyLogger(),
|
||||
)
|
||||
|
||||
import routes.models as models_route
|
||||
|
||||
|
||||
def _repo(
|
||||
repo_id: str,
|
||||
files: list[SimpleNamespace],
|
||||
repo_path: Path,
|
||||
*,
|
||||
revisions: list[SimpleNamespace] | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
repo_id = repo_id,
|
||||
repo_type = "model",
|
||||
repo_path = repo_path,
|
||||
revisions = revisions or [SimpleNamespace(files = files)],
|
||||
)
|
||||
|
||||
|
||||
def _file(
|
||||
name: str,
|
||||
size_on_disk: int,
|
||||
*,
|
||||
blob_path: str | None = None,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
file_name = name,
|
||||
size_on_disk = size_on_disk,
|
||||
blob_path = blob_path,
|
||||
)
|
||||
|
||||
|
||||
def test_iter_gguf_paths_matches_extension_case_insensitively(tmp_path):
|
||||
nested = tmp_path / "snapshots" / "rev"
|
||||
nested.mkdir(parents = True)
|
||||
lower = nested / "Q4_K_M.gguf"
|
||||
upper = nested / "Q8_0.GGUF"
|
||||
other = nested / "README.md"
|
||||
lower.write_text("a")
|
||||
upper.write_text("b")
|
||||
other.write_text("c")
|
||||
|
||||
result = sorted(path.name for path in models_route._iter_gguf_paths(tmp_path))
|
||||
|
||||
assert result == ["Q4_K_M.gguf", "Q8_0.GGUF"]
|
||||
|
||||
|
||||
def test_list_cached_gguf_includes_non_suffix_repo_when_cache_contains_gguf(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
repo = _repo(
|
||||
"HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
[_file("Q4_K_M.gguf", 5_000), _file("README.md", 10)],
|
||||
tmp_path / "models--HauhauCS--Gemma",
|
||||
)
|
||||
scan = SimpleNamespace(repos = [repo])
|
||||
|
||||
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
|
||||
"size_bytes": 5_000,
|
||||
"cache_path": str(repo.repo_path),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_cached_gguf_matches_extension_case_insensitively(monkeypatch, tmp_path):
|
||||
repo = _repo(
|
||||
"Org/Model-Without-Suffix",
|
||||
[_file("Q8_0.GGUF", 7_000)],
|
||||
tmp_path / "models--Org--Model-Without-Suffix",
|
||||
)
|
||||
scan = SimpleNamespace(repos = [repo])
|
||||
|
||||
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "Org/Model-Without-Suffix",
|
||||
"size_bytes": 7_000,
|
||||
"cache_path": str(repo.repo_path),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_cached_gguf_skips_repos_without_positive_gguf_size(monkeypatch, tmp_path):
|
||||
missing = _repo(
|
||||
"Org/ReadmeOnly",
|
||||
[_file("README.md", 10)],
|
||||
tmp_path / "models--Org--ReadmeOnly",
|
||||
)
|
||||
zero = _repo(
|
||||
"Org/ZeroSize",
|
||||
[_file("Q4_K_M.gguf", 0)],
|
||||
tmp_path / "models--Org--ZeroSize",
|
||||
)
|
||||
scan = SimpleNamespace(repos = [missing, zero])
|
||||
|
||||
monkeypatch.setattr(models_route, "_all_hf_cache_scans", lambda: [scan])
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == []
|
||||
|
||||
|
||||
def test_list_cached_gguf_keeps_largest_duplicate_repo_across_scans(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
smaller = _repo(
|
||||
"Org/Dupe",
|
||||
[_file("Q4_K_M.gguf", 2_000)],
|
||||
tmp_path / "models--Org--Dupe-a",
|
||||
)
|
||||
larger = _repo(
|
||||
"org/dupe",
|
||||
[_file("Q4_K_M.gguf", 5_000), _file("Q6_K.gguf", 1_000)],
|
||||
tmp_path / "models--Org--Dupe-b",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [
|
||||
SimpleNamespace(repos = [smaller]),
|
||||
SimpleNamespace(repos = [larger]),
|
||||
],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "org/dupe",
|
||||
"size_bytes": 6_000,
|
||||
"cache_path": str(larger.repo_path),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_cached_gguf_dedupes_shared_blobs_across_revisions(monkeypatch, tmp_path):
|
||||
shared = "blobs/shared-q4"
|
||||
repo = _repo(
|
||||
"Org/SharedBlobRepo",
|
||||
[],
|
||||
tmp_path / "models--Org--SharedBlobRepo",
|
||||
revisions = [
|
||||
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000, blob_path = shared)]),
|
||||
SimpleNamespace(files = [_file("Q4_K_M.gguf", 5_000, blob_path = shared)]),
|
||||
],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [repo])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "Org/SharedBlobRepo",
|
||||
"size_bytes": 5_000,
|
||||
"cache_path": str(repo.repo_path),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_cached_models_skips_non_suffix_repo_when_gguf_files_exist(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
mixed = _repo(
|
||||
"Org/MixedRepo",
|
||||
[
|
||||
_file("Q4_K_M.gguf", 5_000),
|
||||
_file("model.safetensors", 10_000),
|
||||
],
|
||||
tmp_path / "models--Org--MixedRepo",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [mixed])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == []
|
||||
|
||||
|
||||
def test_list_cached_gguf_includes_mixed_repo_with_gguf_and_safetensors(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""Mirror of the _skips_ test: the mixed repo should still surface in
|
||||
cached-gguf so the picker can show it as a GGUF download."""
|
||||
mixed = _repo(
|
||||
"Org/MixedRepo",
|
||||
[
|
||||
_file("Q4_K_M.gguf", 5_000),
|
||||
_file("model.safetensors", 10_000),
|
||||
],
|
||||
tmp_path / "models--Org--MixedRepo",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [mixed])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "Org/MixedRepo",
|
||||
"size_bytes": 5_000,
|
||||
"cache_path": str(mixed.repo_path),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_cached_gguf_handles_none_size_on_disk(monkeypatch, tmp_path):
|
||||
"""A partial/interrupted GGUF download has ``size_on_disk = None``. The
|
||||
route must treat the unknown bytes as zero instead of raising TypeError
|
||||
out of ``sum()`` and wiping the entire response."""
|
||||
partial = _repo(
|
||||
"Org/PartialDownload",
|
||||
[_file("Q4_K_M.gguf", None), _file("Q6_K.gguf", 5_000)],
|
||||
tmp_path / "models--Org--PartialDownload",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [partial])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "Org/PartialDownload",
|
||||
"size_bytes": 5_000,
|
||||
"cache_path": str(partial.repo_path),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_cached_gguf_skips_malformed_repo_without_wiping_response(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""One repo raising during classification must not poison the response
|
||||
for every other repo in the scan."""
|
||||
|
||||
class _ExplodingRepo:
|
||||
repo_id = "Org/Broken"
|
||||
repo_type = "model"
|
||||
repo_path = tmp_path / "models--Org--Broken"
|
||||
|
||||
@property
|
||||
def revisions(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
healthy = _repo(
|
||||
"Org/Healthy",
|
||||
[_file("Q4_K_M.gguf", 5_000)],
|
||||
tmp_path / "models--Org--Healthy",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [_ExplodingRepo(), healthy])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "Org/Healthy",
|
||||
"size_bytes": 5_000,
|
||||
"cache_path": str(healthy.repo_path),
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_cached_gguf_skips_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
|
||||
"""A repo whose only ``.gguf`` artifact is an mmproj vision adapter
|
||||
must not be classified as a GGUF repo: the variant selector filters
|
||||
mmproj out and the picker would otherwise show zero variants."""
|
||||
mmproj_only = _repo(
|
||||
"Org/MmprojOnly",
|
||||
[
|
||||
_file("mmproj-Q8_0.gguf", 5_000),
|
||||
_file("model.safetensors", 10_000),
|
||||
],
|
||||
tmp_path / "models--Org--MmprojOnly",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [mmproj_only])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == []
|
||||
|
||||
|
||||
def test_list_cached_models_includes_repo_with_only_mmproj_gguf(monkeypatch, tmp_path):
|
||||
"""Mirror of the cached-gguf skip: a safetensors repo with an
|
||||
auxiliary mmproj vision adapter must still surface in cached-models
|
||||
so the user can load it as a normal model."""
|
||||
mmproj_aux = _repo(
|
||||
"Org/MmprojAux",
|
||||
[
|
||||
_file("mmproj-Q8_0.gguf", 5_000),
|
||||
_file("model.safetensors", 10_000),
|
||||
],
|
||||
tmp_path / "models--Org--MmprojAux",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [mmproj_aux])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_models(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "Org/MmprojAux",
|
||||
"size_bytes": 15_000,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_cached_gguf_includes_vision_repo_with_main_gguf_and_mmproj(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
"""A vision-capable GGUF repo (main weight + mmproj adapter) is still
|
||||
a GGUF repo. The reported size is the main weight size; mmproj is
|
||||
excluded from the GGUF-size accounting because it is filtered out at
|
||||
classification time."""
|
||||
vision_repo = _repo(
|
||||
"Org/VisionGguf",
|
||||
[
|
||||
_file("Q4_K_M.gguf", 5_000),
|
||||
_file("mmproj-Q8_0.gguf", 1_000),
|
||||
],
|
||||
tmp_path / "models--Org--VisionGguf",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
models_route,
|
||||
"_all_hf_cache_scans",
|
||||
lambda: [SimpleNamespace(repos = [vision_repo])],
|
||||
)
|
||||
|
||||
result = asyncio.run(models_route.list_cached_gguf(current_subject = "test-user"))
|
||||
|
||||
assert result["cached"] == [
|
||||
{
|
||||
"repo_id": "Org/VisionGguf",
|
||||
"size_bytes": 5_000,
|
||||
"cache_path": str(vision_repo.repo_path),
|
||||
}
|
||||
]
|
||||
91
studio/backend/tests/test_data_recipe_github_progress.py
Normal file
91
studio/backend/tests/test_data_recipe_github_progress.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# 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 core.data_recipe.jobs.parse import apply_update, parse_log_message
|
||||
from core.data_recipe.jobs.types import Job
|
||||
from routes.data_recipe.validate import _GITHUB_VALIDATE_NOTE, validate
|
||||
from models.data_recipe import RecipePayload
|
||||
|
||||
|
||||
def test_github_page_log_updates_source_progress_without_cursor():
|
||||
job = Job(job_id = "job-1")
|
||||
job.source_progress_estimated_total = 200
|
||||
|
||||
update = parse_log_message(
|
||||
"[unslothai/unsloth] issues page 2 (+15) cursor=abc123 remaining=2960"
|
||||
)
|
||||
|
||||
assert update is not None
|
||||
apply_update(job, update)
|
||||
|
||||
progress = job.source_progress
|
||||
assert progress is not None
|
||||
assert progress.source == "github"
|
||||
assert progress.status == "fetching"
|
||||
assert progress.repo == "unslothai/unsloth"
|
||||
assert progress.resource == "issues"
|
||||
assert progress.page == 2
|
||||
assert progress.page_items == 15
|
||||
assert progress.fetched_items == 15
|
||||
assert progress.estimated_total == 200
|
||||
assert progress.rate_remaining == 2960
|
||||
assert progress.message is not None
|
||||
assert "cursor" not in progress.message
|
||||
assert "abc123" not in progress.message
|
||||
|
||||
|
||||
def test_github_rate_limit_log_updates_source_progress():
|
||||
job = Job(job_id = "job-1")
|
||||
|
||||
update = parse_log_message("Rate limit hit. Sleeping 123s until reset.")
|
||||
|
||||
assert update is not None
|
||||
apply_update(job, update)
|
||||
|
||||
progress = job.source_progress
|
||||
assert progress is not None
|
||||
assert progress.status == "rate_limited"
|
||||
assert progress.retry_after_sec == 123
|
||||
assert "resume automatically" in (progress.message or "")
|
||||
|
||||
|
||||
def test_github_real_sample_prs_and_trial_limit_are_parsed():
|
||||
job = Job(job_id = "job-1")
|
||||
|
||||
for message in (
|
||||
"[unslothai/unsloth] PRs page 4 (+25) cursor=abc123 remaining=4983",
|
||||
"Trial limit reached for PRs (100)",
|
||||
):
|
||||
update = parse_log_message(message)
|
||||
assert update is not None
|
||||
apply_update(job, update)
|
||||
|
||||
progress = job.source_progress
|
||||
assert progress is not None
|
||||
assert progress.repo == "unslothai/unsloth"
|
||||
assert progress.resource == "pulls"
|
||||
assert progress.page == 4
|
||||
assert progress.fetched_items == 25
|
||||
assert progress.rate_remaining == 4983
|
||||
assert progress.message == "GitHub pulls trial limit reached (100)."
|
||||
|
||||
|
||||
def test_github_validate_skips_live_access_with_honest_note():
|
||||
response = validate(
|
||||
RecipePayload(
|
||||
recipe = {
|
||||
"seed_config": {
|
||||
"source": {
|
||||
"seed_type": "github_repo",
|
||||
"repos": ["unslothai/unsloth"],
|
||||
"item_types": ["issues"],
|
||||
"limit": 1,
|
||||
}
|
||||
},
|
||||
"columns": [{"column_type": "expression", "name": "x", "expr": "1"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.valid is True
|
||||
assert response.raw_detail == _GITHUB_VALIDATE_NOTE
|
||||
598
studio/backend/tests/test_desktop_auth.py
Normal file
598
studio/backend/tests/test_desktop_auth.py
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
import importlib.util
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from auth import storage
|
||||
|
||||
|
||||
@pytest.fixture(autouse = True)
|
||||
def isolated_auth_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
|
||||
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
|
||||
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
||||
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
|
||||
yield
|
||||
|
||||
|
||||
def seed_user(*, must_change_password = False):
|
||||
storage.create_initial_user(
|
||||
username = storage.DEFAULT_ADMIN_USERNAME,
|
||||
password = "human-password-123",
|
||||
jwt_secret = secrets.token_urlsafe(64),
|
||||
must_change_password = must_change_password,
|
||||
)
|
||||
|
||||
|
||||
def auth_client():
|
||||
route_path = Path(__file__).resolve().parents[1] / "routes" / "auth.py"
|
||||
spec = importlib.util.spec_from_file_location("_desktop_auth_route", route_path)
|
||||
auth_route = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(auth_route)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_route.router, prefix = "/api/auth")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def data_recipe_jobs_module():
|
||||
route_path = (
|
||||
Path(__file__).resolve().parents[1] / "routes" / "data_recipe" / "jobs.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_desktop_data_recipe_jobs", route_path
|
||||
)
|
||||
jobs_route = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(jobs_route)
|
||||
return jobs_route
|
||||
|
||||
|
||||
def local_recipe():
|
||||
return {
|
||||
"model_providers": [{"name": "local", "is_local": True}],
|
||||
"model_configs": [{"alias": "local-model", "provider": "local"}],
|
||||
"columns": [{"column_type": "llm-text", "model_alias": "local-model"}],
|
||||
}
|
||||
|
||||
|
||||
def local_recipe_request(token):
|
||||
return SimpleNamespace(
|
||||
headers = {"authorization": f"Bearer {token}"},
|
||||
app = SimpleNamespace(state = SimpleNamespace(server_port = 8888)),
|
||||
scope = {},
|
||||
base_url = "http://testserver/",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def loaded_local_model(monkeypatch):
|
||||
inference_module = SimpleNamespace(
|
||||
get_llama_cpp_backend = lambda: SimpleNamespace(is_loaded = True),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "routes.inference", inference_module)
|
||||
|
||||
|
||||
def test_desktop_secret_round_trip_uses_real_admin_subject():
|
||||
seed_user()
|
||||
raw = storage.create_desktop_secret()
|
||||
|
||||
assert raw.startswith("desktop-")
|
||||
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
||||
assert storage.validate_desktop_secret(raw + "x") is None
|
||||
|
||||
|
||||
def test_create_desktop_secret_rotates_old_secret():
|
||||
seed_user()
|
||||
old = storage.create_desktop_secret()
|
||||
new = storage.create_desktop_secret()
|
||||
|
||||
assert old != new
|
||||
assert storage.validate_desktop_secret(old) is None
|
||||
assert storage.validate_desktop_secret(new) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
|
||||
def test_clear_desktop_secret_invalidates_secret():
|
||||
seed_user()
|
||||
raw = storage.create_desktop_secret()
|
||||
|
||||
storage.clear_desktop_secret()
|
||||
|
||||
assert storage.validate_desktop_secret(raw) is None
|
||||
|
||||
|
||||
def test_ensure_default_admin_does_not_recreate_bootstrap_for_existing_admin():
|
||||
seed_user()
|
||||
|
||||
created = storage.ensure_default_admin()
|
||||
|
||||
assert created is False
|
||||
assert not storage._BOOTSTRAP_PW_PATH.exists()
|
||||
|
||||
|
||||
def test_ensure_default_admin_loads_existing_bootstrap_after_restart(monkeypatch):
|
||||
created = storage.ensure_default_admin()
|
||||
bootstrap_pw = storage._BOOTSTRAP_PW_PATH.read_text().strip()
|
||||
|
||||
monkeypatch.setattr(storage, "_bootstrap_password", None)
|
||||
created_again = storage.ensure_default_admin()
|
||||
|
||||
assert created is True
|
||||
assert storage._BOOTSTRAP_PW_PATH.exists()
|
||||
assert created_again is False
|
||||
assert storage.get_bootstrap_password() == bootstrap_pw
|
||||
|
||||
|
||||
def test_ensure_default_admin_does_not_generate_for_empty_existing_bootstrap():
|
||||
seed_user()
|
||||
storage._BOOTSTRAP_PW_PATH.write_text(" \n")
|
||||
|
||||
created = storage.ensure_default_admin()
|
||||
|
||||
assert created is False
|
||||
assert storage._BOOTSTRAP_PW_PATH.read_text() == " \n"
|
||||
assert storage.get_bootstrap_password() is None
|
||||
|
||||
|
||||
def test_web_login_token_has_no_desktop_marker_and_keeps_password_gate():
|
||||
seed_user(must_change_password = True)
|
||||
client = auth_client()
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json = {
|
||||
"username": storage.DEFAULT_ADMIN_USERNAME,
|
||||
"password": "human-password-123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["must_change_password"] is True
|
||||
payload = jwt.decode(
|
||||
body["access_token"],
|
||||
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
|
||||
algorithms = ["HS256"],
|
||||
)
|
||||
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
|
||||
assert "desktop" not in payload
|
||||
|
||||
gated = client.post(
|
||||
"/api/auth/api-keys",
|
||||
headers = {"Authorization": f"Bearer {body['access_token']}"},
|
||||
json = {"name": "web"},
|
||||
)
|
||||
assert gated.status_code == 403
|
||||
|
||||
|
||||
def test_desktop_login_mints_admin_token_without_clearing_web_password_change():
|
||||
seed_user(must_change_password = True)
|
||||
raw = storage.create_desktop_secret()
|
||||
client = auth_client()
|
||||
|
||||
response = client.post("/api/auth/desktop-login", json = {"secret": raw})
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["access_token"]
|
||||
assert body["refresh_token"]
|
||||
assert body["token_type"] == "bearer"
|
||||
assert body["must_change_password"] is False
|
||||
assert storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME) is True
|
||||
|
||||
payload = jwt.decode(
|
||||
body["access_token"],
|
||||
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
|
||||
algorithms = ["HS256"],
|
||||
)
|
||||
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
|
||||
assert payload["desktop"] is True
|
||||
|
||||
|
||||
def test_desktop_refresh_preserves_desktop_marker():
|
||||
seed_user(must_change_password = True)
|
||||
raw = storage.create_desktop_secret()
|
||||
client = auth_client()
|
||||
login_body = client.post("/api/auth/desktop-login", json = {"secret": raw}).json()
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/refresh",
|
||||
json = {"refresh_token": login_body["refresh_token"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["must_change_password"] is False
|
||||
payload = jwt.decode(
|
||||
body["access_token"],
|
||||
storage.get_jwt_secret(storage.DEFAULT_ADMIN_USERNAME),
|
||||
algorithms = ["HS256"],
|
||||
)
|
||||
assert payload["sub"] == storage.DEFAULT_ADMIN_USERNAME
|
||||
assert payload["desktop"] is True
|
||||
|
||||
|
||||
def test_desktop_session_uses_real_admin_identity_for_api_keys():
|
||||
seed_user(must_change_password = True)
|
||||
raw = storage.create_desktop_secret()
|
||||
client = auth_client()
|
||||
token = client.post("/api/auth/desktop-login", json = {"secret": raw}).json()[
|
||||
"access_token"
|
||||
]
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/api-keys",
|
||||
headers = {"Authorization": f"Bearer {token}"},
|
||||
json = {"name": "desktop"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
rows = storage.list_api_keys(storage.DEFAULT_ADMIN_USERNAME)
|
||||
assert [row["name"] for row in rows] == ["desktop"]
|
||||
|
||||
|
||||
def test_local_recipe_token_authenticates_as_admin_for_desktop_user(loaded_local_model):
|
||||
# _inject_local_providers mints an internal sk-unsloth-* API key (not a
|
||||
# forwarded JWT). The unified API-key path validates as the real admin
|
||||
# user regardless of whether the incoming session was desktop or web.
|
||||
from auth.authentication import create_access_token, get_current_subject
|
||||
|
||||
seed_user(must_change_password = True)
|
||||
jobs_route = data_recipe_jobs_module()
|
||||
incoming_token = create_access_token(
|
||||
subject = storage.DEFAULT_ADMIN_USERNAME,
|
||||
desktop = True,
|
||||
)
|
||||
recipe = local_recipe()
|
||||
|
||||
jobs_route._inject_local_providers(recipe, local_recipe_request(incoming_token))
|
||||
|
||||
local_token = recipe["model_providers"][0]["api_key"]
|
||||
assert local_token.startswith(storage.API_KEY_PREFIX)
|
||||
credentials = HTTPAuthorizationCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = local_token,
|
||||
)
|
||||
assert (
|
||||
asyncio.run(get_current_subject(credentials)) == storage.DEFAULT_ADMIN_USERNAME
|
||||
)
|
||||
|
||||
|
||||
def test_local_recipe_token_authenticates_as_admin_for_web_user(loaded_local_model):
|
||||
# Mirror of the desktop variant: API-key issuance is identical for web
|
||||
# and desktop incoming tokens; auth via get_current_subject works the same.
|
||||
from auth.authentication import create_access_token, get_current_subject
|
||||
|
||||
seed_user(must_change_password = False)
|
||||
jobs_route = data_recipe_jobs_module()
|
||||
incoming_token = create_access_token(subject = storage.DEFAULT_ADMIN_USERNAME)
|
||||
recipe = local_recipe()
|
||||
|
||||
jobs_route._inject_local_providers(recipe, local_recipe_request(incoming_token))
|
||||
|
||||
local_token = recipe["model_providers"][0]["api_key"]
|
||||
assert local_token.startswith(storage.API_KEY_PREFIX)
|
||||
credentials = HTTPAuthorizationCredentials(
|
||||
scheme = "Bearer",
|
||||
credentials = local_token,
|
||||
)
|
||||
assert (
|
||||
asyncio.run(get_current_subject(credentials)) == storage.DEFAULT_ADMIN_USERNAME
|
||||
)
|
||||
|
||||
|
||||
def test_desktop_login_rejects_invalid_secret():
|
||||
seed_user(must_change_password = False)
|
||||
client = auth_client()
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/desktop-login",
|
||||
json = {"secret": "desktop-invalid"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_write_desktop_secret_file_is_0600_on_unix(tmp_path):
|
||||
from unsloth_cli.commands import studio as studio_cli
|
||||
|
||||
path = tmp_path / ".desktop_secret"
|
||||
if platform.system() != "Windows":
|
||||
path.write_text("old-secret")
|
||||
os.chmod(path, 0o644)
|
||||
|
||||
studio_cli._write_auth_secret(path, "desktop-secret")
|
||||
|
||||
assert path.read_text() == "desktop-secret"
|
||||
if platform.system() != "Windows":
|
||||
assert oct(path.stat().st_mode & 0o777) == "0o600"
|
||||
|
||||
|
||||
def test_reset_password_removes_desktop_secret_files(tmp_path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
from unsloth_cli.commands import studio as studio_cli
|
||||
|
||||
auth_dir = tmp_path / "auth"
|
||||
auth_dir.mkdir()
|
||||
(auth_dir / "auth.db").write_text("db")
|
||||
(auth_dir / ".bootstrap_password").write_text("boot")
|
||||
(auth_dir / ".desktop_secret").write_text("new")
|
||||
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
|
||||
|
||||
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert not (auth_dir / "auth.db").exists()
|
||||
assert not (auth_dir / ".bootstrap_password").exists()
|
||||
assert not (auth_dir / ".desktop_secret").exists()
|
||||
|
||||
|
||||
def test_reset_password_removes_desktop_secret_files_without_db(tmp_path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
from unsloth_cli.commands import studio as studio_cli
|
||||
|
||||
auth_dir = tmp_path / "auth"
|
||||
auth_dir.mkdir()
|
||||
(auth_dir / ".desktop_secret").write_text("new")
|
||||
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
|
||||
|
||||
result = CliRunner().invoke(studio_cli.studio_app, ["reset-password"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert not (auth_dir / ".desktop_secret").exists()
|
||||
|
||||
|
||||
def test_desktop_capabilities_json_reports_rollout_safe_flags():
|
||||
from typer.testing import CliRunner
|
||||
import unsloth_cli.commands.studio as studio_cli
|
||||
|
||||
result = CliRunner().invoke(
|
||||
studio_cli.studio_app,
|
||||
["desktop-capabilities", "--json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
body = json.loads(result.output)
|
||||
assert body["desktop_protocol_version"] == 1
|
||||
assert body["supports_provision_desktop_auth"] is True
|
||||
assert body["supports_api_only"] is True
|
||||
assert isinstance(body["version"], str)
|
||||
|
||||
|
||||
def test_health_response_reports_desktop_capability_fields(monkeypatch):
|
||||
router_stub = SimpleNamespace(
|
||||
auth_router = APIRouter(),
|
||||
data_recipe_router = APIRouter(),
|
||||
datasets_router = APIRouter(),
|
||||
export_router = APIRouter(),
|
||||
inference_router = APIRouter(),
|
||||
inference_studio_router = APIRouter(),
|
||||
models_router = APIRouter(),
|
||||
training_history_router = APIRouter(),
|
||||
training_router = APIRouter(),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "routes", router_stub)
|
||||
|
||||
import studio.backend.main as backend_main
|
||||
|
||||
monkeypatch.setattr(backend_main._hw_module, "CHAT_ONLY", False)
|
||||
|
||||
body = asyncio.run(backend_main.health_check())
|
||||
|
||||
assert body["desktop_protocol_version"] == 1
|
||||
assert body["supports_desktop_auth"] is True
|
||||
|
||||
|
||||
def test_provision_desktop_auth_writes_secret_and_creates_db_without_backend_deps(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
auth_dir = tmp_path / "auth"
|
||||
auth_dir.mkdir()
|
||||
|
||||
code = """
|
||||
import builtins
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typer.testing import CliRunner
|
||||
|
||||
studio_home = Path(sys.argv[1])
|
||||
real_import = builtins.__import__
|
||||
|
||||
def guarded_import(name, *args, **kwargs):
|
||||
blocked = ("auth", "fastapi", "structlog", "utils")
|
||||
if name in blocked or name.startswith(("auth.", "utils.")):
|
||||
raise ModuleNotFoundError(name)
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
builtins.__import__ = guarded_import
|
||||
from unsloth_cli.commands import studio as studio_cli
|
||||
|
||||
studio_cli.STUDIO_HOME = studio_home
|
||||
result = CliRunner().invoke(studio_cli.studio_app, ["provision-desktop-auth"])
|
||||
if result.exit_code != 0:
|
||||
print(result.output)
|
||||
if result.exception is not None:
|
||||
raise result.exception
|
||||
raise SystemExit(result.exit_code)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code, str(tmp_path)],
|
||||
cwd = Path(__file__).resolve().parents[3],
|
||||
env = {**os.environ, "PYTHONPATH": "."},
|
||||
text = True,
|
||||
capture_output = True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
secret = (auth_dir / ".desktop_secret").read_text()
|
||||
assert secret.startswith("desktop-")
|
||||
|
||||
conn = sqlite3.connect(auth_dir / "auth.db")
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
user = conn.execute(
|
||||
"""
|
||||
SELECT username, password_salt, password_hash, must_change_password
|
||||
FROM auth_user
|
||||
"""
|
||||
).fetchone()
|
||||
app_secrets = {
|
||||
row["key"]: row["value"]
|
||||
for row in conn.execute("SELECT key, value FROM app_secrets")
|
||||
}
|
||||
refresh_columns = {
|
||||
row["name"] for row in conn.execute("PRAGMA table_info(refresh_tokens)")
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
bootstrap_password = (auth_dir / ".bootstrap_password").read_text().strip()
|
||||
bootstrap_hash = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
bootstrap_password.encode("utf-8"),
|
||||
user["password_salt"].encode("utf-8"),
|
||||
100_000,
|
||||
).hex()
|
||||
|
||||
assert bootstrap_password
|
||||
assert user["username"] == "unsloth"
|
||||
assert user["must_change_password"] == 1
|
||||
assert bootstrap_hash == user["password_hash"]
|
||||
assert len(app_secrets["api_key_pbkdf2_salt"]) == 64
|
||||
assert len(app_secrets["desktop_secret_hash"]) == 64
|
||||
assert app_secrets["desktop_secret_created_at"]
|
||||
assert "is_desktop" in refresh_columns
|
||||
|
||||
monkeypatch.setattr(storage, "DB_PATH", auth_dir / "auth.db")
|
||||
monkeypatch.setattr(storage, "_api_key_pbkdf2_salt_cache", None)
|
||||
assert storage.validate_desktop_secret(secret) == storage.DEFAULT_ADMIN_USERNAME
|
||||
assert storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME) is True
|
||||
|
||||
|
||||
def test_provision_desktop_auth_keeps_existing_admin_password(tmp_path, monkeypatch):
|
||||
from typer.testing import CliRunner
|
||||
from unsloth_cli.commands import studio as studio_cli
|
||||
|
||||
auth_dir = tmp_path / "auth"
|
||||
auth_dir.mkdir()
|
||||
monkeypatch.setattr(studio_cli, "STUDIO_HOME", tmp_path)
|
||||
|
||||
conn = sqlite3.connect(auth_dir / "auth.db")
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE auth_user (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_salt TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
jwt_secret TEXT NOT NULL,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO auth_user (
|
||||
username, password_salt, password_hash, jwt_secret, must_change_password
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
("unsloth", "existing-salt", "existing-hash", "existing-jwt", 0),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
result = CliRunner().invoke(studio_cli.studio_app, ["provision-desktop-auth"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert not (auth_dir / ".bootstrap_password").exists()
|
||||
conn = sqlite3.connect(auth_dir / "auth.db")
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
user = conn.execute(
|
||||
"""
|
||||
SELECT password_salt, password_hash, jwt_secret, must_change_password
|
||||
FROM auth_user WHERE username = ?
|
||||
""",
|
||||
("unsloth",),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert dict(user) == {
|
||||
"password_salt": "existing-salt",
|
||||
"password_hash": "existing-hash",
|
||||
"jwt_secret": "existing-jwt",
|
||||
"must_change_password": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_update_password_clears_desktop_secret():
|
||||
seed_user()
|
||||
raw = storage.create_desktop_secret()
|
||||
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
changed = storage.update_password(
|
||||
storage.DEFAULT_ADMIN_USERNAME, "new-admin-password"
|
||||
)
|
||||
assert changed is True
|
||||
assert storage.validate_desktop_secret(raw) is None
|
||||
|
||||
|
||||
def test_update_password_on_unknown_user_leaves_desktop_secret_intact():
|
||||
seed_user()
|
||||
raw = storage.create_desktop_secret()
|
||||
|
||||
changed = storage.update_password("not-a-user", "irrelevant")
|
||||
assert changed is False
|
||||
assert storage.validate_desktop_secret(raw) == storage.DEFAULT_ADMIN_USERNAME
|
||||
|
||||
|
||||
def test_desktop_auth_provision_has_bounded_timeout():
|
||||
rs_path = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "studio"
|
||||
/ "src-tauri"
|
||||
/ "src"
|
||||
/ "desktop_auth.rs"
|
||||
)
|
||||
src = rs_path.read_text()
|
||||
start = src.index("async fn provision_desktop_auth(")
|
||||
depth = 0
|
||||
body_start = src.index("{", start)
|
||||
body_end = None
|
||||
for i in range(body_start, len(src)):
|
||||
c = src[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
body_end = i + 1
|
||||
break
|
||||
assert body_end is not None
|
||||
body = src[start:body_end]
|
||||
assert "tokio::time::timeout" in body
|
||||
import re
|
||||
|
||||
m = re.search(r"Duration::from_secs\(\s*(\d+)\s*\)", body)
|
||||
assert m is not None
|
||||
seconds = int(m.group(1))
|
||||
assert 5 <= seconds <= 120
|
||||
179
studio/backend/tests/test_export_log_cursor.py
Normal file
179
studio/backend/tests/test_export_log_cursor.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Regression tests for the export log ring-buffer cursor semantics.
|
||||
|
||||
Context: the live export log SSE stream has a race where the frontend
|
||||
opens the SSE connection AFTER the POST that starts the export. Any
|
||||
lines the worker subprocess emits during the gap between POST and SSE
|
||||
connect get buffered with seqs 1..k, and then the SSE default cursor
|
||||
`get_current_log_seq()` returns k -- so lines 1..k are forever
|
||||
unreachable to that client.
|
||||
|
||||
Fix: `clear_logs()` snapshots the pre-run seq into `_run_start_seq`
|
||||
(exposed via `get_run_start_seq()`), and `routes/export.py` defaults
|
||||
the SSE cursor to that snapshot instead of the current seq. Every line
|
||||
appended during the current run has seq strictly greater than the
|
||||
snapshot, so the client sees the full run regardless of when it
|
||||
connects.
|
||||
|
||||
These tests exercise the orchestrator-side contract only (no
|
||||
subprocess, no FastAPI, no frontend). The routes-level integration
|
||||
with get_run_start_seq() is a one-line edit covered by manual testing
|
||||
and the frontend build.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# Backend root on sys.path so `from core.export.orchestrator import ...`
|
||||
# and friends resolve without the studio app bootstrap.
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
if str(_BACKEND_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_BACKEND_DIR))
|
||||
|
||||
# ExportOrchestrator imports structlog and a few heavy modules at the
|
||||
# top of orchestrator.py. Stub the ones we don't need in these unit
|
||||
# tests so the import succeeds on machines without the full studio
|
||||
# venv.
|
||||
_loggers_stub = types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
# structlog is only used for a module-level import; a bare stub is
|
||||
# enough because we never call into it in these tests.
|
||||
sys.modules.setdefault("structlog", types.ModuleType("structlog"))
|
||||
|
||||
# utils.paths.outputs_root is only called inside scan_checkpoints which
|
||||
# we don't hit in these tests. Provide a stub module so the top-level
|
||||
# import in orchestrator.py resolves.
|
||||
_utils_pkg = types.ModuleType("utils")
|
||||
_utils_pkg.__path__ = [] # mark as package
|
||||
_utils_paths_stub = types.ModuleType("utils.paths")
|
||||
_utils_paths_stub.outputs_root = lambda: Path("/tmp")
|
||||
sys.modules.setdefault("utils", _utils_pkg)
|
||||
sys.modules.setdefault("utils.paths", _utils_paths_stub)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orchestrator():
|
||||
"""Fresh ExportOrchestrator with only the log-buffer state exercised."""
|
||||
from core.export.orchestrator import ExportOrchestrator
|
||||
|
||||
return ExportOrchestrator()
|
||||
|
||||
|
||||
def _append(orch, line: str, stream: str = "stdout") -> None:
|
||||
"""Shortcut for simulating a worker log message."""
|
||||
orch._append_log({"type": "log", "stream": stream, "line": line, "ts": 0.0})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clear_logs() semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_run_start_seq_is_zero_before_any_logs(orchestrator) -> None:
|
||||
"""A brand-new orchestrator must report run_start_seq == 0 so a
|
||||
first SSE connection picks up every line from seq 1 onward."""
|
||||
assert orchestrator.get_run_start_seq() == 0
|
||||
|
||||
|
||||
def test_clear_logs_snapshots_current_seq(orchestrator) -> None:
|
||||
"""clear_logs() must capture _log_seq BEFORE clearing the buffer,
|
||||
so subsequent runs can anchor their SSE cursor at the snapshot."""
|
||||
_append(orchestrator, "old run line 1")
|
||||
_append(orchestrator, "old run line 2")
|
||||
_append(orchestrator, "old run line 3")
|
||||
assert orchestrator.get_current_log_seq() == 3
|
||||
|
||||
orchestrator.clear_logs()
|
||||
|
||||
assert orchestrator.get_run_start_seq() == 3
|
||||
assert orchestrator.get_current_log_seq() == 3 # seq counter preserved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Race regression: SSE connects AFTER lines have been emitted
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sse_default_cursor_catches_all_current_run_lines(orchestrator) -> None:
|
||||
"""Simulate the POST-then-SSE race: worker starts emitting lines
|
||||
immediately after clear_logs(), SSE connects several lines later.
|
||||
Using get_run_start_seq() as the default cursor MUST return every
|
||||
line emitted since clear_logs() ran.
|
||||
|
||||
Pre-fix, the SSE defaulted to get_current_log_seq() at connect
|
||||
time, which would return the last-seen seq and miss lines N+1..M.
|
||||
"""
|
||||
# Previous run leaves some buffered lines.
|
||||
_append(orchestrator, "previous run line A")
|
||||
_append(orchestrator, "previous run line B")
|
||||
|
||||
# New run starts: orchestrator clears the buffer and snapshots seq.
|
||||
orchestrator.clear_logs()
|
||||
run_start = orchestrator.get_run_start_seq()
|
||||
|
||||
# Worker emits early lines BEFORE the SSE connects.
|
||||
_append(orchestrator, "Importing Unsloth...")
|
||||
_append(orchestrator, "Loading checkpoint: /foo/bar")
|
||||
_append(orchestrator, "Starting export...")
|
||||
|
||||
# SSE connects now and asks "give me everything after the run
|
||||
# start cursor".
|
||||
entries, new_cursor = orchestrator.get_logs_since(run_start)
|
||||
|
||||
# All three early lines must be present. Pre-fix this was [].
|
||||
lines = [e["line"] for e in entries]
|
||||
assert lines == [
|
||||
"Importing Unsloth...",
|
||||
"Loading checkpoint: /foo/bar",
|
||||
"Starting export...",
|
||||
]
|
||||
assert new_cursor == entries[-1]["seq"]
|
||||
|
||||
|
||||
def test_sse_default_cursor_excludes_previous_run(orchestrator) -> None:
|
||||
"""After clear_logs(), lines from the PREVIOUS run must not leak
|
||||
into the new run's SSE stream. Pre-fix this worked correctly
|
||||
(clear_logs cleared the deque); the fix must preserve it.
|
||||
"""
|
||||
_append(orchestrator, "previous run line 1")
|
||||
_append(orchestrator, "previous run line 2")
|
||||
_append(orchestrator, "previous run line 3")
|
||||
assert orchestrator.get_current_log_seq() == 3
|
||||
|
||||
orchestrator.clear_logs()
|
||||
run_start = orchestrator.get_run_start_seq()
|
||||
|
||||
_append(orchestrator, "new run line")
|
||||
|
||||
entries, _ = orchestrator.get_logs_since(run_start)
|
||||
assert [e["line"] for e in entries] == ["new run line"]
|
||||
|
||||
|
||||
def test_clear_logs_twice_advances_run_start(orchestrator) -> None:
|
||||
"""Back-to-back clear_logs() calls (e.g. cleanup -> load ->
|
||||
export in the same dialog session) must each re-anchor run_start
|
||||
at the current seq, so successive runs each start with a fresh
|
||||
low-water mark."""
|
||||
_append(orchestrator, "run 1 line a")
|
||||
_append(orchestrator, "run 1 line b")
|
||||
|
||||
orchestrator.clear_logs()
|
||||
assert orchestrator.get_run_start_seq() == 2
|
||||
|
||||
_append(orchestrator, "run 2 line a")
|
||||
_append(orchestrator, "run 2 line b")
|
||||
_append(orchestrator, "run 2 line c")
|
||||
|
||||
orchestrator.clear_logs()
|
||||
assert orchestrator.get_run_start_seq() == 5
|
||||
|
|
@ -746,7 +746,15 @@ class TestRouteErrors(unittest.TestCase):
|
|||
):
|
||||
with self.assertRaises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
inference_route.load_model(request, current_subject = "test-user")
|
||||
inference_route.load_model(
|
||||
request,
|
||||
SimpleNamespace(
|
||||
app = SimpleNamespace(
|
||||
state = SimpleNamespace(llama_parallel_slots = 1),
|
||||
),
|
||||
),
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(exc_info.exception.status_code, 400)
|
||||
|
|
@ -886,7 +894,15 @@ class TestRouteErrors(unittest.TestCase):
|
|||
):
|
||||
with self.assertRaises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
inference_route.load_model(request, current_subject = "test-user")
|
||||
inference_route.load_model(
|
||||
request,
|
||||
SimpleNamespace(
|
||||
app = SimpleNamespace(
|
||||
state = SimpleNamespace(llama_parallel_slots = 1),
|
||||
),
|
||||
),
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(exc_info.exception.status_code, 400)
|
||||
|
|
@ -942,7 +958,15 @@ class TestRouteErrors(unittest.TestCase):
|
|||
):
|
||||
with self.assertRaises(HTTPException) as exc_info:
|
||||
asyncio.run(
|
||||
inference_route.load_model(request, current_subject = "test-user")
|
||||
inference_route.load_model(
|
||||
request,
|
||||
SimpleNamespace(
|
||||
app = SimpleNamespace(
|
||||
state = SimpleNamespace(llama_parallel_slots = 1),
|
||||
),
|
||||
),
|
||||
current_subject = "test-user",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(exc_info.exception.status_code, 400)
|
||||
|
|
@ -1025,6 +1049,182 @@ class TestMinGpuVram(unittest.TestCase):
|
|||
|
||||
|
||||
class TestPerGpuFitGuardAllCounts(unittest.TestCase):
|
||||
def test_training_estimate_resolves_attention_without_raising(self):
|
||||
with (
|
||||
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
|
||||
patch(
|
||||
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
|
||||
return_value = (8 * (1024**3), "config"),
|
||||
),
|
||||
patch(
|
||||
"utils.hardware.hardware._resolve_model_identifier_for_gpu_estimate",
|
||||
return_value = "unsloth/test",
|
||||
),
|
||||
patch(
|
||||
"utils.hardware.hardware._load_config_for_gpu_estimate",
|
||||
return_value = SimpleNamespace(
|
||||
hidden_size = 4096,
|
||||
num_hidden_layers = 32,
|
||||
num_attention_heads = 32,
|
||||
num_key_value_heads = 8,
|
||||
intermediate_size = 14336,
|
||||
vocab_size = 128256,
|
||||
tie_word_embeddings = False,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"utils.hardware.hardware._determine_attention_impl_for_gpu_estimate",
|
||||
return_value = "eager",
|
||||
),
|
||||
patch("utils.hardware.hardware.get_visible_gpu_count", return_value = 1),
|
||||
):
|
||||
_, metadata = estimate_required_model_memory_gb(
|
||||
"unsloth/test",
|
||||
training_type = "LoRA/QLoRA",
|
||||
load_in_4bit = True,
|
||||
)
|
||||
|
||||
self.assertEqual(metadata.get("estimation_mode"), "detailed")
|
||||
self.assertEqual(metadata.get("attention_implementation"), "eager")
|
||||
|
||||
def test_training_estimate_falls_back_when_attention_resolution_fails(self):
|
||||
with (
|
||||
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
|
||||
patch(
|
||||
"utils.hardware.hardware.estimate_fp16_model_size_bytes",
|
||||
return_value = (8 * (1024**3), "config"),
|
||||
),
|
||||
patch(
|
||||
"utils.hardware.hardware._resolve_model_identifier_for_gpu_estimate",
|
||||
return_value = "unsloth/test",
|
||||
),
|
||||
patch(
|
||||
"utils.hardware.hardware._load_config_for_gpu_estimate",
|
||||
return_value = SimpleNamespace(
|
||||
hidden_size = 4096,
|
||||
num_hidden_layers = 32,
|
||||
num_attention_heads = 32,
|
||||
num_key_value_heads = 8,
|
||||
intermediate_size = 14336,
|
||||
vocab_size = 128256,
|
||||
tie_word_embeddings = False,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"utils.hardware.hardware._determine_attention_impl_for_gpu_estimate",
|
||||
side_effect = RuntimeError("attention unavailable"),
|
||||
),
|
||||
patch("utils.hardware.hardware.get_visible_gpu_count", return_value = 1),
|
||||
):
|
||||
_, metadata = estimate_required_model_memory_gb(
|
||||
"unsloth/test",
|
||||
training_type = "LoRA/QLoRA",
|
||||
load_in_4bit = True,
|
||||
)
|
||||
|
||||
self.assertEqual(metadata.get("estimation_mode"), "detailed")
|
||||
self.assertEqual(
|
||||
metadata.get("attention_implementation"),
|
||||
"eager",
|
||||
)
|
||||
|
||||
def test_attention_resolver_does_not_mutate_loaded_config(self):
|
||||
from utils.hardware import hardware as hardware_module
|
||||
|
||||
config = SimpleNamespace(
|
||||
hidden_size = 1024,
|
||||
num_hidden_layers = 2,
|
||||
num_attention_heads = 8,
|
||||
num_key_value_heads = 8,
|
||||
intermediate_size = 2048,
|
||||
vocab_size = 1024,
|
||||
tie_word_embeddings = True,
|
||||
)
|
||||
|
||||
def _stub_resolver(model_class, cfg):
|
||||
cfg._attn_implementation = "eager"
|
||||
return "eager"
|
||||
|
||||
with patch(
|
||||
"unsloth.models._utils.resolve_attention_implementation",
|
||||
side_effect = _stub_resolver,
|
||||
):
|
||||
hardware_module._determine_attention_impl_for_gpu_estimate(config)
|
||||
|
||||
self.assertFalse(hasattr(config, "_attn_implementation"))
|
||||
|
||||
def test_attention_resolver_handles_missing_model_mapping(self):
|
||||
from utils.hardware import hardware as hardware_module
|
||||
|
||||
config = SimpleNamespace(
|
||||
hidden_size = 1024,
|
||||
num_hidden_layers = 2,
|
||||
num_attention_heads = 8,
|
||||
num_key_value_heads = 8,
|
||||
intermediate_size = 2048,
|
||||
vocab_size = 1024,
|
||||
tie_word_embeddings = True,
|
||||
)
|
||||
captured = {}
|
||||
|
||||
def _stub_resolver(model_class, cfg):
|
||||
captured["model_class"] = model_class
|
||||
return "eager"
|
||||
|
||||
from transformers import AutoModel, AutoModelForCausalLM
|
||||
|
||||
with (
|
||||
patch.object(AutoModelForCausalLM, "_model_mapping", new = None),
|
||||
patch.object(AutoModel, "_model_mapping", new = None),
|
||||
patch(
|
||||
"unsloth.models._utils.resolve_attention_implementation",
|
||||
side_effect = _stub_resolver,
|
||||
),
|
||||
):
|
||||
result = hardware_module._determine_attention_impl_for_gpu_estimate(config)
|
||||
|
||||
self.assertEqual(result, "eager")
|
||||
self.assertIsNone(captured["model_class"])
|
||||
|
||||
def test_attention_resolver_does_not_mutate_nested_text_config(self):
|
||||
from utils.hardware import hardware as hardware_module
|
||||
|
||||
text_config = SimpleNamespace(
|
||||
hidden_size = 1024,
|
||||
num_hidden_layers = 2,
|
||||
num_attention_heads = 8,
|
||||
num_key_value_heads = 8,
|
||||
intermediate_size = 2048,
|
||||
vocab_size = 1024,
|
||||
tie_word_embeddings = True,
|
||||
)
|
||||
config = SimpleNamespace(
|
||||
hidden_size = 1024,
|
||||
num_hidden_layers = 2,
|
||||
num_attention_heads = 8,
|
||||
num_key_value_heads = 8,
|
||||
intermediate_size = 2048,
|
||||
vocab_size = 1024,
|
||||
tie_word_embeddings = True,
|
||||
text_config = text_config,
|
||||
)
|
||||
|
||||
def _stub_resolver(model_class, cfg):
|
||||
cfg._attn_implementation = "eager"
|
||||
inner = getattr(cfg, "text_config", None)
|
||||
if inner is not None:
|
||||
inner._attn_implementation = "eager"
|
||||
return "eager"
|
||||
|
||||
with patch(
|
||||
"unsloth.models._utils.resolve_attention_implementation",
|
||||
side_effect = _stub_resolver,
|
||||
):
|
||||
hardware_module._determine_attention_impl_for_gpu_estimate(config)
|
||||
|
||||
self.assertFalse(hasattr(config, "_attn_implementation"))
|
||||
self.assertFalse(hasattr(text_config, "_attn_implementation"))
|
||||
|
||||
def test_min_per_gpu_generated_for_all_visible_counts(self):
|
||||
with (
|
||||
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
|
||||
|
|
@ -1101,3 +1301,123 @@ class TestXpuRejection(_GpuCacheResetMixin, unittest.TestCase):
|
|||
with patch("utils.hardware.hardware.get_device", return_value = DeviceType.XPU):
|
||||
with self.assertRaisesRegex(ValueError, "only supported on CUDA"):
|
||||
prepare_gpu_selection([0], model_name = "unsloth/test")
|
||||
|
||||
|
||||
class TestEstimateFp16ModelSizeBytesPrefersLocalWeights(unittest.TestCase):
|
||||
def _run(
|
||||
self,
|
||||
model_path,
|
||||
*,
|
||||
config_bytes,
|
||||
local_bytes,
|
||||
safetensors_params = None,
|
||||
config = object(),
|
||||
):
|
||||
from utils.hardware import hardware as hardware_module
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_resolve_model_identifier_for_gpu_estimate",
|
||||
return_value = model_path,
|
||||
),
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_get_hf_safetensors_total_params",
|
||||
return_value = safetensors_params,
|
||||
),
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_load_config_for_gpu_estimate",
|
||||
return_value = config,
|
||||
),
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_estimate_fp16_model_size_bytes_from_config",
|
||||
return_value = config_bytes,
|
||||
),
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_get_local_weight_size_bytes",
|
||||
return_value = local_bytes,
|
||||
),
|
||||
):
|
||||
return hardware_module.estimate_fp16_model_size_bytes(model_path)
|
||||
|
||||
def test_local_weight_bytes_preferred_when_larger_than_config(self):
|
||||
bytes_, src = self._run(
|
||||
"/local/vlm",
|
||||
config_bytes = 2 * (1 << 30),
|
||||
local_bytes = 20 * (1 << 30),
|
||||
)
|
||||
self.assertEqual(bytes_, 20 * (1 << 30))
|
||||
self.assertEqual(src, "weight_bytes")
|
||||
|
||||
def test_config_bytes_preferred_when_larger_than_local(self):
|
||||
bytes_, src = self._run(
|
||||
"/local/text-only",
|
||||
config_bytes = 20 * (1 << 30),
|
||||
local_bytes = 2 * (1 << 30),
|
||||
)
|
||||
self.assertEqual(bytes_, 20 * (1 << 30))
|
||||
self.assertEqual(src, "config")
|
||||
|
||||
def test_config_bytes_returned_when_no_local_weights(self):
|
||||
bytes_, src = self._run(
|
||||
"/local/no-weights",
|
||||
config_bytes = 5 * (1 << 30),
|
||||
local_bytes = None,
|
||||
)
|
||||
self.assertEqual(bytes_, 5 * (1 << 30))
|
||||
self.assertEqual(src, "config")
|
||||
|
||||
def test_local_bytes_returned_when_config_resolution_fails(self):
|
||||
bytes_, src = self._run(
|
||||
"/local/no-config",
|
||||
config_bytes = None,
|
||||
local_bytes = 7 * (1 << 30),
|
||||
config = None,
|
||||
)
|
||||
self.assertEqual(bytes_, 7 * (1 << 30))
|
||||
self.assertEqual(src, "weight_bytes")
|
||||
|
||||
def test_equal_local_and_config_keeps_config_label(self):
|
||||
# why: tie-breaker is "local must be strictly larger" so an exact
|
||||
# match keeps the config-derived path.
|
||||
same = 8 * (1 << 30)
|
||||
bytes_, src = self._run(
|
||||
"/local/equal",
|
||||
config_bytes = same,
|
||||
local_bytes = same,
|
||||
)
|
||||
self.assertEqual(bytes_, same)
|
||||
self.assertEqual(src, "config")
|
||||
|
||||
def test_remote_safetensors_path_unaffected_by_local_weights(self):
|
||||
from utils.hardware import hardware as hardware_module
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_resolve_model_identifier_for_gpu_estimate",
|
||||
return_value = "owner/repo",
|
||||
),
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_get_hf_safetensors_total_params",
|
||||
return_value = 1_000_000_000,
|
||||
),
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_load_config_for_gpu_estimate",
|
||||
) as mock_load,
|
||||
patch.object(
|
||||
hardware_module,
|
||||
"_get_local_weight_size_bytes",
|
||||
) as mock_local,
|
||||
):
|
||||
bytes_, src = hardware_module.estimate_fp16_model_size_bytes("owner/repo")
|
||||
self.assertEqual(bytes_, 2 * 1_000_000_000)
|
||||
self.assertEqual(src, "safetensors")
|
||||
mock_load.assert_not_called()
|
||||
mock_local.assert_not_called()
|
||||
|
|
|
|||
98
studio/backend/tests/test_host_defaults.py
Normal file
98
studio/backend/tests/test_host_defaults.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests that Unsloth Studio defaults to 127.0.0.1 (loopback) not 0.0.0.0.
|
||||
|
||||
Uses AST parsing to inspect source-level defaults without requiring the
|
||||
full studio venv (run.py has heavy dependencies like structlog/uvicorn).
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
_RUN_PY = Path(__file__).resolve().parent.parent / "run.py"
|
||||
|
||||
|
||||
def _parse_function_param_defaults(source: str, func_name: str) -> dict:
|
||||
"""Return {param_name: default_value} for a named function in *source*.
|
||||
|
||||
Only handles ast.Constant defaults (strings, ints, bools).
|
||||
"""
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and node.name == func_name
|
||||
):
|
||||
result = {}
|
||||
all_args = node.args.args
|
||||
defaults = node.args.defaults
|
||||
# Defaults are right-aligned against the args list
|
||||
offset = len(all_args) - len(defaults)
|
||||
for i, default in enumerate(defaults):
|
||||
arg_name = all_args[offset + i].arg
|
||||
if isinstance(default, ast.Constant):
|
||||
result[arg_name] = default.value
|
||||
return result
|
||||
return {}
|
||||
|
||||
|
||||
def _parse_argparse_add_argument_default(source: str, option_name: str):
|
||||
"""Return the 'default' kwarg value for add_argument(option_name, ...) in *source*.
|
||||
|
||||
Walks the entire module so the call can live in __main__ or in a helper
|
||||
function — only handles ast.Constant defaults.
|
||||
"""
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not (isinstance(func, ast.Attribute) and func.attr == "add_argument"):
|
||||
continue
|
||||
if not node.args:
|
||||
continue
|
||||
first_arg = node.args[0]
|
||||
if not (isinstance(first_arg, ast.Constant) and first_arg.value == option_name):
|
||||
continue
|
||||
for kw in node.keywords:
|
||||
if kw.arg == "default" and isinstance(kw.value, ast.Constant):
|
||||
return kw.value.value
|
||||
return None
|
||||
|
||||
|
||||
def test_run_server_default_host_is_loopback():
|
||||
"""run_server() parameter default for 'host' must be 127.0.0.1, not 0.0.0.0.
|
||||
|
||||
Binding to 0.0.0.0 by default exposes the service on all network
|
||||
interfaces, contradicting the documented "privacy first / 100% local"
|
||||
guarantee. Loopback (127.0.0.1) is the least-permissive default;
|
||||
users who need network access can pass -H 0.0.0.0 explicitly.
|
||||
"""
|
||||
source = _RUN_PY.read_text()
|
||||
defaults = _parse_function_param_defaults(source, "run_server")
|
||||
assert (
|
||||
"host" in defaults
|
||||
), "run_server() must have a 'host' parameter with a default"
|
||||
host_default = defaults["host"]
|
||||
assert host_default == "127.0.0.1", (
|
||||
f"run_server() host default must be '127.0.0.1' (loopback) "
|
||||
f"but got '{host_default}'. Binding to '{host_default}' by default "
|
||||
f"exposes the service beyond localhost."
|
||||
)
|
||||
|
||||
|
||||
def test_argparse_default_host_is_loopback():
|
||||
"""argparse --host add_argument default must be 127.0.0.1.
|
||||
|
||||
When run.py is invoked directly (python run.py), the argparse default
|
||||
should match the function default so direct execution is equally safe.
|
||||
"""
|
||||
source = _RUN_PY.read_text()
|
||||
host_default = _parse_argparse_add_argument_default(source, "--host")
|
||||
assert (
|
||||
host_default is not None
|
||||
), "Could not find add_argument('--host', ...) in run.py"
|
||||
assert (
|
||||
host_default == "127.0.0.1"
|
||||
), f"run.py argparse --host default must be '127.0.0.1', got '{host_default}'"
|
||||
File diff suppressed because it is too large
Load diff
243
studio/backend/tests/test_llama_cpp_cache_aware_disk_check.py
Normal file
243
studio/backend/tests/test_llama_cpp_cache_aware_disk_check.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for the cache-aware disk-space preflight in
|
||||
``LlamaCppBackend.load_model``.
|
||||
|
||||
The preflight used to compare the repo's total GGUF download size against
|
||||
free disk without accounting for bytes already present in the Hugging
|
||||
Face cache. That made re-loading a cached large model (e.g.
|
||||
``unsloth/MiniMax-M2.7-GGUF`` at 131 GB) fail cold whenever free disk was
|
||||
below the full weight footprint, even though nothing needed
|
||||
downloading.
|
||||
|
||||
These tests exercise the preflight arithmetic in isolation by driving
|
||||
``get_paths_info`` and ``try_to_load_from_cache`` through ``mock.patch``.
|
||||
No network, GPU, or subprocess use.
|
||||
|
||||
Cross-platform: Linux, macOS, Windows, WSL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import types as _types
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub heavy / unavailable external dependencies before importing the
|
||||
# module under test. Same pattern as test_kv_cache_estimation.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
# loggers
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
# structlog
|
||||
_structlog_stub = _types.ModuleType("structlog")
|
||||
sys.modules.setdefault("structlog", _structlog_stub)
|
||||
|
||||
# httpx
|
||||
_httpx_stub = _types.ModuleType("httpx")
|
||||
for _exc_name in (
|
||||
"ConnectError",
|
||||
"TimeoutException",
|
||||
"ReadTimeout",
|
||||
"ReadError",
|
||||
"RemoteProtocolError",
|
||||
"CloseError",
|
||||
):
|
||||
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
|
||||
|
||||
|
||||
class _FakeTimeout:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
|
||||
_httpx_stub.Timeout = _FakeTimeout
|
||||
_httpx_stub.Client = type(
|
||||
"Client",
|
||||
(),
|
||||
{
|
||||
"__init__": lambda self, **kw: None,
|
||||
"__enter__": lambda self: self,
|
||||
"__exit__": lambda self, *a: None,
|
||||
},
|
||||
)
|
||||
sys.modules.setdefault("httpx", _httpx_stub)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GIB = 1024**3
|
||||
|
||||
|
||||
class _FakePathInfo:
|
||||
"""Mimics huggingface_hub's RepoFile-ish return type from get_paths_info."""
|
||||
|
||||
def __init__(self, path: str, size: int):
|
||||
self.path = path
|
||||
self.size = size
|
||||
|
||||
|
||||
def _preflight(
|
||||
repo_files,
|
||||
cached_files,
|
||||
free_bytes,
|
||||
hf_repo = "unsloth/Example-GGUF",
|
||||
hf_token = None,
|
||||
):
|
||||
"""Run the preflight arithmetic as written in llama_cpp.py and return
|
||||
the decision outcome as a dict.
|
||||
|
||||
``repo_files``: list of (filename, remote_bytes).
|
||||
``cached_files``: dict {filename: on_disk_bytes} for files already in cache.
|
||||
``free_bytes``: value returned by shutil.disk_usage(cache_dir).free.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
path_infos = [_FakePathInfo(name, size) for name, size in repo_files]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
# Create SPARSE files for the cached ones so os.path.exists /
|
||||
# os.path.getsize pass without actually allocating bytes on disk.
|
||||
# This is critical when simulating multi-GB models.
|
||||
cache_paths = {}
|
||||
for name, sz in cached_files.items():
|
||||
p = Path(tmp) / name.replace("/", "_")
|
||||
with open(p, "wb") as fh:
|
||||
if sz > 0:
|
||||
fh.truncate(sz) # sparse allocation: no data blocks written
|
||||
cache_paths[name] = str(p)
|
||||
|
||||
def fake_try_to_load_from_cache(repo_id, filename):
|
||||
return cache_paths.get(filename)
|
||||
|
||||
# Mirror the same variable names and control flow as the real code
|
||||
# so behavioral drift is caught immediately.
|
||||
total_bytes = sum((p.size or 0) for p in path_infos)
|
||||
already_cached_bytes = 0
|
||||
for p in path_infos:
|
||||
if not p.size:
|
||||
continue
|
||||
cached_path = fake_try_to_load_from_cache(hf_repo, p.path)
|
||||
if isinstance(cached_path, str) and os.path.exists(cached_path):
|
||||
try:
|
||||
on_disk = os.path.getsize(cached_path)
|
||||
except OSError:
|
||||
on_disk = 0
|
||||
if on_disk >= p.size:
|
||||
already_cached_bytes += p.size
|
||||
|
||||
total_download_bytes = max(0, total_bytes - already_cached_bytes)
|
||||
needed_download = total_download_bytes > free_bytes
|
||||
return {
|
||||
"total_bytes": total_bytes,
|
||||
"already_cached_bytes": already_cached_bytes,
|
||||
"total_download_bytes": total_download_bytes,
|
||||
"would_raise_disk_error": (needed_download and total_download_bytes > 0),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCacheAwarePreflight:
|
||||
def test_fully_cached_model_does_not_require_disk(self):
|
||||
"""The MiniMax case: 131 GB weights cached, only 36 GB free.
|
||||
Preflight must not raise."""
|
||||
shards = [(f"UD-Q4_K_XL/shard-{i}.gguf", 35 * GIB) for i in range(4)]
|
||||
cached = {name: size for name, size in shards}
|
||||
out = _preflight(
|
||||
repo_files = shards,
|
||||
cached_files = cached,
|
||||
free_bytes = 36 * GIB,
|
||||
)
|
||||
assert out["total_download_bytes"] == 0
|
||||
assert out["already_cached_bytes"] == 140 * GIB
|
||||
assert out["would_raise_disk_error"] is False
|
||||
|
||||
def test_partial_cache_only_counts_remaining_bytes(self):
|
||||
"""Two of four shards cached: preflight against remaining 70 GB."""
|
||||
shards = [(f"UD-Q4_K_XL/shard-{i}.gguf", 35 * GIB) for i in range(4)]
|
||||
cached = {
|
||||
shards[0][0]: shards[0][1],
|
||||
shards[1][0]: shards[1][1],
|
||||
}
|
||||
out = _preflight(
|
||||
repo_files = shards,
|
||||
cached_files = cached,
|
||||
free_bytes = 80 * GIB,
|
||||
)
|
||||
assert out["already_cached_bytes"] == 70 * GIB
|
||||
assert out["total_download_bytes"] == 70 * GIB
|
||||
assert out["would_raise_disk_error"] is False
|
||||
|
||||
def test_partial_cache_insufficient_disk_for_rest_still_raises(self):
|
||||
"""Two of four shards cached; remaining 70 GB still bigger than
|
||||
free disk -> preflight correctly wants to raise."""
|
||||
shards = [(f"UD-Q4_K_XL/shard-{i}.gguf", 35 * GIB) for i in range(4)]
|
||||
cached = {
|
||||
shards[0][0]: shards[0][1],
|
||||
shards[1][0]: shards[1][1],
|
||||
}
|
||||
out = _preflight(
|
||||
repo_files = shards,
|
||||
cached_files = cached,
|
||||
free_bytes = 50 * GIB,
|
||||
)
|
||||
assert out["total_download_bytes"] == 70 * GIB
|
||||
assert out["would_raise_disk_error"] is True
|
||||
|
||||
def test_nothing_cached_preserves_existing_behavior(self):
|
||||
"""Cold-cache path still compares full download vs free disk."""
|
||||
shards = [("UD-Q4_K_XL/shard-0.gguf", 40 * GIB)]
|
||||
out = _preflight(
|
||||
repo_files = shards,
|
||||
cached_files = {},
|
||||
free_bytes = 50 * GIB,
|
||||
)
|
||||
assert out["already_cached_bytes"] == 0
|
||||
assert out["total_download_bytes"] == 40 * GIB
|
||||
assert out["would_raise_disk_error"] is False
|
||||
|
||||
def test_incomplete_cached_blob_is_not_credited(self):
|
||||
"""A partial file on disk (e.g. interrupted download) is not
|
||||
counted as cached -- we still require bytes for it."""
|
||||
shards = [("UD-Q4_K_XL/shard-0.gguf", 40 * GIB)]
|
||||
partial = {"UD-Q4_K_XL/shard-0.gguf": 10 * GIB}
|
||||
out = _preflight(
|
||||
repo_files = shards,
|
||||
cached_files = partial,
|
||||
free_bytes = 50 * GIB,
|
||||
)
|
||||
assert out["already_cached_bytes"] == 0
|
||||
assert out["total_download_bytes"] == 40 * GIB
|
||||
assert out["would_raise_disk_error"] is False
|
||||
|
||||
def test_zero_size_path_infos_do_not_crash(self):
|
||||
"""A path_info with size=0 should not be credited or break the
|
||||
arithmetic."""
|
||||
shards = [("mmproj.gguf", 0), ("UD-Q4_K_XL/shard-0.gguf", 40 * GIB)]
|
||||
out = _preflight(
|
||||
repo_files = shards,
|
||||
cached_files = {},
|
||||
free_bytes = 50 * GIB,
|
||||
)
|
||||
assert out["already_cached_bytes"] == 0
|
||||
assert out["total_bytes"] == 40 * GIB
|
||||
393
studio/backend/tests/test_llama_cpp_context_fit.py
Normal file
393
studio/backend/tests/test_llama_cpp_context_fit.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for the GGUF load-time context auto-fit decision.
|
||||
|
||||
Guards two regressions in ``LlamaCppBackend.load_model``:
|
||||
|
||||
1. **Auto mode on weights-exceed-VRAM** (``n_ctx == 0``): when the model
|
||||
weights alone exceed 90% of every GPU subset's free memory, the
|
||||
auto-pick loop used to exit without matching, leaving
|
||||
``effective_ctx`` at the model's native context (e.g. 196608 for
|
||||
MiniMax-M2.7). The intended default per Studio's UI spec is 4096 so
|
||||
the slider lands on a usable value; the user can still drag higher
|
||||
and trigger ``--fit on`` with a warning.
|
||||
|
||||
2. **Explicit ctx silently shrunk when KV overflows**: with fittable
|
||||
weights but a requested ctx whose KV cache pushes total memory over
|
||||
90% of VRAM, the old code binary-searched a smaller ctx and emitted
|
||||
``-c <capped> -ngl -1`` without informing the caller. The UI had
|
||||
already surfaced its "might be slower" warning and expects the user's
|
||||
explicit ctx to be honored with ``--fit on`` flexing ``-ngl`` instead.
|
||||
|
||||
Tests avoid GPU probing, subprocess spawning, and GGUF I/O by driving the
|
||||
post-metadata decision block directly against a stubbed instance.
|
||||
|
||||
Requires no GPU, network, or external libraries beyond pytest.
|
||||
Cross-platform: Linux, macOS, Windows, WSL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types as _types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub heavy / unavailable external dependencies before importing the
|
||||
# module under test. Same pattern as test_kv_cache_estimation.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
# loggers
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
# structlog
|
||||
_structlog_stub = _types.ModuleType("structlog")
|
||||
sys.modules.setdefault("structlog", _structlog_stub)
|
||||
|
||||
# httpx
|
||||
_httpx_stub = _types.ModuleType("httpx")
|
||||
for _exc_name in (
|
||||
"ConnectError",
|
||||
"TimeoutException",
|
||||
"ReadTimeout",
|
||||
"ReadError",
|
||||
"RemoteProtocolError",
|
||||
"CloseError",
|
||||
):
|
||||
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
|
||||
|
||||
|
||||
class _FakeTimeout:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
|
||||
_httpx_stub.Timeout = _FakeTimeout
|
||||
_httpx_stub.Client = type(
|
||||
"Client",
|
||||
(),
|
||||
{
|
||||
"__init__": lambda self, **kw: None,
|
||||
"__enter__": lambda self: self,
|
||||
"__exit__": lambda self, *a: None,
|
||||
},
|
||||
)
|
||||
sys.modules.setdefault("httpx", _httpx_stub)
|
||||
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GIB = 1024**3
|
||||
FALLBACK_CTX = 4096
|
||||
|
||||
|
||||
def _make_backend(
|
||||
native_ctx = 131072,
|
||||
n_layers = 80,
|
||||
n_kv_heads = 8,
|
||||
n_heads = 64,
|
||||
kv_key_length = 128,
|
||||
kv_value_length = 128,
|
||||
):
|
||||
"""Create a LlamaCppBackend instance with GGUF metadata fields set and
|
||||
the helpers used by the decision block stubbed out."""
|
||||
inst = LlamaCppBackend.__new__(LlamaCppBackend)
|
||||
inst._context_length = native_ctx
|
||||
inst._n_layers = n_layers
|
||||
inst._n_kv_heads = n_kv_heads
|
||||
inst._n_heads = n_heads
|
||||
inst._embedding_length = 8192
|
||||
inst._kv_key_length = kv_key_length
|
||||
inst._kv_value_length = kv_value_length
|
||||
inst._kv_lora_rank = None
|
||||
inst._sliding_window = None
|
||||
inst._sliding_window_pattern = None
|
||||
inst._ssm_inner_size = None
|
||||
inst._full_attention_interval = None
|
||||
inst._key_length_mla = None
|
||||
inst._n_kv_heads_by_layer = None
|
||||
inst._kv_key_length_swa = None
|
||||
inst._kv_value_length_swa = None
|
||||
return inst
|
||||
|
||||
|
||||
def _drive(
|
||||
n_ctx,
|
||||
model_gib,
|
||||
gpus,
|
||||
native_ctx = 131072,
|
||||
kv_per_token_bytes = 325_000,
|
||||
can_estimate_kv = True,
|
||||
):
|
||||
"""Drive the post-metadata portion of load_model with stubbed inputs.
|
||||
|
||||
Mirrors the decision block at llama_cpp.py:1137-1296 so we can assert
|
||||
the command that would be built, without subprocesses or GPU probes.
|
||||
"""
|
||||
inst = _make_backend(native_ctx = native_ctx)
|
||||
model_size = int(model_gib * GIB)
|
||||
cache_type_kv = None
|
||||
|
||||
def fake_estimate(n_ctx_, _type = None, **_kwargs):
|
||||
return 0 if n_ctx_ <= 0 else n_ctx_ * kv_per_token_bytes
|
||||
|
||||
inst._estimate_kv_cache_bytes = fake_estimate
|
||||
inst._can_estimate_kv = lambda: can_estimate_kv
|
||||
|
||||
context_length = inst._context_length
|
||||
|
||||
effective_ctx = n_ctx if n_ctx > 0 else (context_length or 0)
|
||||
max_available_ctx = context_length or effective_ctx
|
||||
if n_ctx > 0:
|
||||
effective_ctx = n_ctx
|
||||
elif context_length is not None:
|
||||
effective_ctx = context_length
|
||||
else:
|
||||
effective_ctx = 0
|
||||
original_ctx = effective_ctx
|
||||
max_available_ctx = context_length or effective_ctx
|
||||
|
||||
gpu_indices, use_fit = None, True
|
||||
explicit_ctx = n_ctx > 0
|
||||
|
||||
if gpus and inst._can_estimate_kv() and effective_ctx > 0:
|
||||
native_ctx_for_cap = context_length or effective_ctx
|
||||
if native_ctx_for_cap > 0:
|
||||
ranked_for_cap = sorted(gpus, key = lambda g: g[1], reverse = True)
|
||||
best_cap = 0
|
||||
for n_gpus in range(1, len(ranked_for_cap) + 1):
|
||||
subset = ranked_for_cap[:n_gpus]
|
||||
pool_mib = sum(free for _, free in subset)
|
||||
capped = inst._fit_context_to_vram(
|
||||
native_ctx_for_cap,
|
||||
pool_mib,
|
||||
model_size,
|
||||
cache_type_kv,
|
||||
)
|
||||
kv = inst._estimate_kv_cache_bytes(capped, cache_type_kv)
|
||||
total_mib = (model_size + kv) / (1024 * 1024)
|
||||
if total_mib <= pool_mib * 0.90:
|
||||
best_cap = max(best_cap, capped)
|
||||
if best_cap > 0:
|
||||
max_available_ctx = best_cap
|
||||
|
||||
if explicit_ctx:
|
||||
requested_total = model_size + inst._estimate_kv_cache_bytes(
|
||||
effective_ctx, cache_type_kv
|
||||
)
|
||||
gpu_indices, use_fit = inst._select_gpus(requested_total, gpus)
|
||||
else:
|
||||
ranked = sorted(gpus, key = lambda g: g[1], reverse = True)
|
||||
matched = False
|
||||
for n_gpus in range(1, len(ranked) + 1):
|
||||
subset = ranked[:n_gpus]
|
||||
pool_mib = sum(free for _, free in subset)
|
||||
capped = inst._fit_context_to_vram(
|
||||
effective_ctx,
|
||||
pool_mib,
|
||||
model_size,
|
||||
cache_type_kv,
|
||||
)
|
||||
kv = inst._estimate_kv_cache_bytes(capped, cache_type_kv)
|
||||
total_mib = (model_size + kv) / (1024 * 1024)
|
||||
if total_mib <= pool_mib * 0.90:
|
||||
effective_ctx = capped
|
||||
gpu_indices = sorted(idx for idx, _ in subset)
|
||||
use_fit = False
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
effective_ctx = min(FALLBACK_CTX, effective_ctx)
|
||||
elif gpus:
|
||||
gpu_indices, use_fit = inst._select_gpus(model_size, gpus)
|
||||
if use_fit and not explicit_ctx:
|
||||
effective_ctx = (
|
||||
min(FALLBACK_CTX, effective_ctx) if effective_ctx > 0 else FALLBACK_CTX
|
||||
)
|
||||
|
||||
return {
|
||||
"c_arg": effective_ctx if effective_ctx > 0 else 0,
|
||||
"use_fit": use_fit,
|
||||
"gpu_indices": gpu_indices,
|
||||
"max_available_ctx": max_available_ctx,
|
||||
"original_ctx": original_ctx,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto mode, model weights exceed VRAM (Bug A guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoModeWeightsExceedVRAM:
|
||||
"""``n_ctx == 0`` on a model whose weights don't fit anywhere."""
|
||||
|
||||
def test_minimax_like_single_gpu(self):
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 131,
|
||||
gpus = [(0, 97_000)],
|
||||
native_ctx = 196608,
|
||||
)
|
||||
assert plan["c_arg"] == FALLBACK_CTX
|
||||
assert plan["use_fit"] is True
|
||||
assert plan["gpu_indices"] is None
|
||||
# UI slider ceiling stays at native: user can still drag higher
|
||||
# and get the "might be slower" path.
|
||||
assert plan["max_available_ctx"] == 196608
|
||||
|
||||
def test_multi_gpu_all_subsets_fail(self):
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 400,
|
||||
gpus = [(0, 80_000), (1, 80_000), (2, 80_000), (3, 80_000)],
|
||||
native_ctx = 131072,
|
||||
)
|
||||
assert plan["c_arg"] == FALLBACK_CTX
|
||||
assert plan["use_fit"] is True
|
||||
assert plan["gpu_indices"] is None
|
||||
|
||||
def test_no_kv_metadata_auto(self):
|
||||
"""File-size-only fallback path also defaults to 4096."""
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 131,
|
||||
gpus = [(0, 97_000)],
|
||||
native_ctx = 196608,
|
||||
can_estimate_kv = False,
|
||||
)
|
||||
assert plan["c_arg"] == FALLBACK_CTX
|
||||
assert plan["use_fit"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Explicit ctx, KV overflows fittable weights (Bug B guard)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExplicitCtxRespectsUser:
|
||||
"""``n_ctx > 0`` must never be silently shrunk."""
|
||||
|
||||
def test_fittable_weights_oversized_kv(self):
|
||||
# 8 GB weights + 131k ctx KV on 24 GB VRAM.
|
||||
# Budget = 21.6 GB, KV at 131k >> 13.6 GB remaining, so
|
||||
# _select_gpus flips use_fit=True.
|
||||
plan = _drive(
|
||||
n_ctx = 131072,
|
||||
model_gib = 8,
|
||||
gpus = [(0, 24_000)],
|
||||
native_ctx = 131072,
|
||||
)
|
||||
assert plan["c_arg"] == 131072
|
||||
assert plan["use_fit"] is True
|
||||
assert plan["gpu_indices"] is None
|
||||
|
||||
def test_explicit_that_fits_uses_ngl(self):
|
||||
plan = _drive(
|
||||
n_ctx = 8192,
|
||||
model_gib = 8,
|
||||
gpus = [(0, 24_000)],
|
||||
native_ctx = 131072,
|
||||
)
|
||||
assert plan["c_arg"] == 8192
|
||||
assert plan["use_fit"] is False
|
||||
assert plan["gpu_indices"] == [0]
|
||||
|
||||
def test_explicit_on_weights_exceed_vram(self):
|
||||
# User drags the slider to 32k on a too-big model: honored.
|
||||
plan = _drive(
|
||||
n_ctx = 32768,
|
||||
model_gib = 131,
|
||||
gpus = [(0, 97_000)],
|
||||
native_ctx = 196608,
|
||||
)
|
||||
assert plan["c_arg"] == 32768
|
||||
assert plan["use_fit"] is True
|
||||
|
||||
def test_explicit_at_fallback_on_too_big(self):
|
||||
plan = _drive(
|
||||
n_ctx = FALLBACK_CTX,
|
||||
model_gib = 131,
|
||||
gpus = [(0, 97_000)],
|
||||
native_ctx = 196608,
|
||||
)
|
||||
assert plan["c_arg"] == FALLBACK_CTX
|
||||
assert plan["use_fit"] is True
|
||||
|
||||
def test_explicit_below_floor_honored(self):
|
||||
# 2048 is below --fit-ctx default; still honored since user set it.
|
||||
plan = _drive(
|
||||
n_ctx = 2048,
|
||||
model_gib = 8,
|
||||
gpus = [(0, 24_000)],
|
||||
)
|
||||
assert plan["c_arg"] == 2048
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-regression: fittable + auto still auto-picks largest fitting ctx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFittableAutoPickRegressions:
|
||||
def test_small_model_one_gpu(self):
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 8,
|
||||
gpus = [(0, 24_000)],
|
||||
native_ctx = 131072,
|
||||
kv_per_token_bytes = 8192,
|
||||
)
|
||||
assert plan["use_fit"] is False
|
||||
assert plan["gpu_indices"] == [0]
|
||||
assert plan["c_arg"] > FALLBACK_CTX
|
||||
|
||||
def test_medium_model_needs_multi_gpu(self):
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 60,
|
||||
gpus = [(0, 40_000), (1, 40_000)],
|
||||
native_ctx = 131072,
|
||||
kv_per_token_bytes = 8192,
|
||||
)
|
||||
assert plan["use_fit"] is False
|
||||
assert plan["gpu_indices"] == [0, 1]
|
||||
|
||||
def test_no_kv_metadata_fittable_auto(self):
|
||||
plan = _drive(
|
||||
n_ctx = 0,
|
||||
model_gib = 8,
|
||||
gpus = [(0, 24_000)],
|
||||
native_ctx = 131072,
|
||||
can_estimate_kv = False,
|
||||
)
|
||||
assert plan["use_fit"] is False
|
||||
assert plan["gpu_indices"] == [0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Platform-agnostic input shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform_tag", ["linux", "windows", "mac", "rocm"])
|
||||
def test_identical_decision_across_platforms(platform_tag):
|
||||
"""The decision function takes ``[(gpu_idx, free_mib), ...]`` regardless
|
||||
of how upstream (nvidia-smi / nvidia-smi.exe / Metal / rocm-smi) produced
|
||||
it. Identical inputs must yield identical plans."""
|
||||
plan_a = _drive(n_ctx = 0, model_gib = 8, gpus = [(0, 24_000)])
|
||||
plan_b = _drive(n_ctx = 0, model_gib = 8, gpus = [(0, 24_000)])
|
||||
assert plan_a == plan_b, platform_tag
|
||||
258
studio/backend/tests/test_llama_cpp_load_progress.py
Normal file
258
studio/backend/tests/test_llama_cpp_load_progress.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Tests for ``LlamaCppBackend.load_progress()``.
|
||||
|
||||
The chat settings flow and the training overlay both show a generic
|
||||
"Starting model..." spinner during the window after a GGUF download
|
||||
finishes and before llama-server reports healthy. For small models
|
||||
that window is a second or two and nobody notices. For large MoE GGUFs
|
||||
(MiniMax-M2.7, Qwen3.5-397B-A17B, etc.) the llama-server process spends
|
||||
minutes in kernel state D, paging tens or hundreds of GB of shards
|
||||
into the page cache. The UI has no way to show a real progress bar,
|
||||
rate, or ETA during that window.
|
||||
|
||||
``load_progress()`` samples ``/proc/<pid>/status VmRSS`` (what the
|
||||
kernel has actually paged in) against the total shard file size on
|
||||
disk, so the frontend can render a real bar plus rate/ETA. This
|
||||
module pins that contract:
|
||||
|
||||
* returns ``None`` when no load is in flight
|
||||
* returns ``{"phase": "mmap", ...}`` while the subprocess is alive
|
||||
but ``_healthy`` is False
|
||||
* returns ``{"phase": "ready", ...}`` once ``_healthy`` flips
|
||||
* ``bytes_total`` is derived from the resolved on-disk path
|
||||
(which the paired fix assigns to ``self._gguf_path`` on both the
|
||||
local-GGUF and HF-download code paths)
|
||||
* ``bytes_loaded`` is VmRSS in bytes, capped by total, rounded
|
||||
* ``fraction`` is clamped to 0..1 and rounded to 4 decimal places
|
||||
|
||||
Linux-only via ``/proc``. On platforms without ``/proc`` the method
|
||||
returns ``None`` instead of raising.
|
||||
Cross-platform test: skips cleanly on macOS / Windows if ``/proc`` is
|
||||
not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types as _types
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub heavy / unavailable external dependencies before importing the
|
||||
# module under test. Same pattern as test_kv_cache_estimation.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
|
||||
_structlog_stub = _types.ModuleType("structlog")
|
||||
sys.modules.setdefault("structlog", _structlog_stub)
|
||||
|
||||
_httpx_stub = _types.ModuleType("httpx")
|
||||
for _exc_name in (
|
||||
"ConnectError",
|
||||
"TimeoutException",
|
||||
"ReadTimeout",
|
||||
"ReadError",
|
||||
"RemoteProtocolError",
|
||||
"CloseError",
|
||||
):
|
||||
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
|
||||
|
||||
|
||||
class _FakeTimeout:
|
||||
def __init__(self, *a, **kw):
|
||||
pass
|
||||
|
||||
|
||||
_httpx_stub.Timeout = _FakeTimeout
|
||||
_httpx_stub.Client = type(
|
||||
"Client",
|
||||
(),
|
||||
{
|
||||
"__init__": lambda self, **kw: None,
|
||||
"__enter__": lambda self: self,
|
||||
"__exit__": lambda self, *a: None,
|
||||
},
|
||||
)
|
||||
sys.modules.setdefault("httpx", _httpx_stub)
|
||||
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_instance():
|
||||
inst = LlamaCppBackend.__new__(LlamaCppBackend)
|
||||
inst._process = None
|
||||
inst._gguf_path = None
|
||||
inst._healthy = False
|
||||
return inst
|
||||
|
||||
|
||||
class _FakeProc:
|
||||
"""Minimal stand-in for subprocess.Popen that just carries a pid."""
|
||||
|
||||
def __init__(self, pid: int):
|
||||
self.pid = pid
|
||||
|
||||
|
||||
def _write_sparse_file(path: Path, size_bytes: int) -> None:
|
||||
"""Create a sparse file of the given size without allocating blocks."""
|
||||
with open(path, "wb") as fh:
|
||||
if size_bytes > 0:
|
||||
fh.truncate(size_bytes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadProgressEmptyStates:
|
||||
def test_returns_none_when_no_process(self):
|
||||
inst = _make_instance()
|
||||
assert inst.load_progress() is None
|
||||
|
||||
def test_returns_none_when_process_has_no_pid(self):
|
||||
inst = _make_instance()
|
||||
inst._process = _FakeProc(pid = None) # type: ignore[arg-type]
|
||||
assert inst.load_progress() is None
|
||||
|
||||
|
||||
class TestLoadProgressSingleShard:
|
||||
def test_mmap_phase_for_alive_but_unhealthy(self, tmp_path):
|
||||
"""VmRSS below total -> phase='mmap', fraction reflects progress."""
|
||||
gguf = tmp_path / "model.gguf"
|
||||
_write_sparse_file(gguf, 40 * 1024**3) # 40 GB
|
||||
|
||||
inst = _make_instance()
|
||||
inst._process = _FakeProc(pid = os.getpid()) # use our own pid
|
||||
inst._gguf_path = str(gguf)
|
||||
inst._healthy = False
|
||||
|
||||
# Patch /proc read to claim 10 GB RSS.
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if str(path).startswith("/proc/"):
|
||||
import io
|
||||
|
||||
return io.StringIO(f"Name:\ttest\nVmRSS:\t{10 * 1024 ** 2}\tkB\n")
|
||||
return open(path, *args, **kwargs) # fall through
|
||||
|
||||
with patch("builtins.open", side_effect = fake_open):
|
||||
out = inst.load_progress()
|
||||
|
||||
assert out is not None
|
||||
assert out["phase"] == "mmap"
|
||||
assert out["bytes_total"] == 40 * 1024**3
|
||||
assert out["bytes_loaded"] == 10 * 1024**3
|
||||
assert 0.24 < out["fraction"] < 0.26 # ~25%
|
||||
|
||||
def test_ready_phase_when_healthy(self, tmp_path):
|
||||
gguf = tmp_path / "model.gguf"
|
||||
_write_sparse_file(gguf, 8 * 1024**3)
|
||||
|
||||
inst = _make_instance()
|
||||
inst._process = _FakeProc(pid = os.getpid())
|
||||
inst._gguf_path = str(gguf)
|
||||
inst._healthy = True
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if str(path).startswith("/proc/"):
|
||||
import io
|
||||
|
||||
return io.StringIO(f"VmRSS:\t{8 * 1024 ** 2}\tkB\n")
|
||||
return open(path, *args, **kwargs)
|
||||
|
||||
with patch("builtins.open", side_effect = fake_open):
|
||||
out = inst.load_progress()
|
||||
|
||||
assert out is not None
|
||||
assert out["phase"] == "ready"
|
||||
assert out["bytes_total"] == 8 * 1024**3
|
||||
assert out["bytes_loaded"] == 8 * 1024**3
|
||||
assert out["fraction"] == 1.0
|
||||
|
||||
|
||||
class TestLoadProgressMultiShard:
|
||||
"""Shard-aware total: for ``*-00001-of-00004.gguf`` primaries the
|
||||
method sums sibling files with the same prefix."""
|
||||
|
||||
def test_sharded_total_aggregates_siblings(self, tmp_path):
|
||||
for i in range(1, 5):
|
||||
_write_sparse_file(
|
||||
tmp_path / f"model-{i:05d}-of-00004.gguf",
|
||||
size_bytes = 20 * 1024**3,
|
||||
)
|
||||
# Drop an unrelated .gguf in the same folder -- must not be counted.
|
||||
_write_sparse_file(tmp_path / "mmproj-BF16.gguf", 2 * 1024**3)
|
||||
|
||||
inst = _make_instance()
|
||||
inst._process = _FakeProc(pid = os.getpid())
|
||||
inst._gguf_path = str(tmp_path / "model-00001-of-00004.gguf")
|
||||
inst._healthy = False
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if str(path).startswith("/proc/"):
|
||||
import io
|
||||
|
||||
return io.StringIO("VmRSS:\t0\tkB\n")
|
||||
return open(path, *args, **kwargs)
|
||||
|
||||
with patch("builtins.open", side_effect = fake_open):
|
||||
out = inst.load_progress()
|
||||
|
||||
assert out is not None
|
||||
assert out["bytes_total"] == 80 * 1024**3 # 4 x 20 GB, no mmproj
|
||||
|
||||
|
||||
class TestLoadProgressDegradation:
|
||||
"""Broken / unusual inputs never raise; they produce best-effort output."""
|
||||
|
||||
def test_missing_gguf_path_still_reports_rss(self, tmp_path):
|
||||
inst = _make_instance()
|
||||
inst._process = _FakeProc(pid = os.getpid())
|
||||
inst._gguf_path = None
|
||||
inst._healthy = False
|
||||
|
||||
def fake_open(path, *args, **kwargs):
|
||||
if str(path).startswith("/proc/"):
|
||||
import io
|
||||
|
||||
return io.StringIO("VmRSS:\t1024\tkB\n")
|
||||
return open(path, *args, **kwargs)
|
||||
|
||||
with patch("builtins.open", side_effect = fake_open):
|
||||
out = inst.load_progress()
|
||||
|
||||
assert out is not None
|
||||
assert out["phase"] == "mmap"
|
||||
assert out["bytes_total"] == 0
|
||||
assert out["bytes_loaded"] == 1024 * 1024
|
||||
assert out["fraction"] == 0.0
|
||||
|
||||
def test_unreadable_proc_returns_none(self, tmp_path):
|
||||
inst = _make_instance()
|
||||
# Pid that doesn't exist -> /proc read fails.
|
||||
inst._process = _FakeProc(pid = 999_999_999)
|
||||
inst._gguf_path = str(tmp_path / "model.gguf") # doesn't need to exist
|
||||
inst._healthy = False
|
||||
|
||||
out = inst.load_progress()
|
||||
# FileNotFoundError on /proc path -> load_progress returns None.
|
||||
assert out is None
|
||||
202
studio/backend/tests/test_llama_cpp_load_progress_live.py
Normal file
202
studio/backend/tests/test_llama_cpp_load_progress_live.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Live, no-mock integration test for ``LlamaCppBackend.load_progress()``.
|
||||
|
||||
The companion files (``test_llama_cpp_load_progress.py`` and
|
||||
``test_llama_cpp_load_progress_matrix.py``) patch ``builtins.open`` to
|
||||
feed synthetic VmRSS values. This file is the opposite: it uses **real**
|
||||
subprocesses, **real** file sizes, and the **real** ``/proc``
|
||||
interface. It is the sanity check that the contract we keep in the
|
||||
mocked tests still maps to what the kernel actually returns on a live
|
||||
Linux system.
|
||||
|
||||
Why both: the mocked tests can be fooled by a buggy implementation that
|
||||
parses ``/proc`` output in a format the kernel no longer uses, or that
|
||||
makes assumptions about ``Path.stat()`` vs ``os.path.getsize``. This
|
||||
file hits the real APIs so any format drift gets caught.
|
||||
|
||||
Skipped cleanly on non-Linux (no ``/proc``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import types as _types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Same stubs as the matrix file (keep self-contained so the file can be
|
||||
# run standalone as well as via the full suite).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
_loggers_stub = _types.ModuleType("loggers")
|
||||
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
||||
sys.modules.setdefault("loggers", _loggers_stub)
|
||||
_structlog_stub = _types.ModuleType("structlog")
|
||||
sys.modules.setdefault("structlog", _structlog_stub)
|
||||
_httpx_stub = _types.ModuleType("httpx")
|
||||
for _exc in (
|
||||
"ConnectError",
|
||||
"TimeoutException",
|
||||
"ReadTimeout",
|
||||
"ReadError",
|
||||
"RemoteProtocolError",
|
||||
"CloseError",
|
||||
):
|
||||
setattr(_httpx_stub, _exc, type(_exc, (Exception,), {}))
|
||||
_httpx_stub.Timeout = type("Timeout", (), {"__init__": lambda self, *a, **k: None})
|
||||
_httpx_stub.Client = type(
|
||||
"Client",
|
||||
(),
|
||||
{
|
||||
"__init__": lambda self, **kw: None,
|
||||
"__enter__": lambda self: self,
|
||||
"__exit__": lambda self, *a: None,
|
||||
},
|
||||
)
|
||||
sys.modules.setdefault("httpx", _httpx_stub)
|
||||
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not Path("/proc").exists(),
|
||||
reason = "live /proc test is Linux-only",
|
||||
)
|
||||
|
||||
|
||||
def _make_backend(pid: int, gguf_path: str, healthy: bool = False):
|
||||
inst = LlamaCppBackend.__new__(LlamaCppBackend)
|
||||
inst._process = type("P", (), {"pid": pid})()
|
||||
inst._gguf_path = gguf_path
|
||||
inst._healthy = healthy
|
||||
return inst
|
||||
|
||||
|
||||
def test_live_rss_matches_kernel_vmrss(tmp_path):
|
||||
"""Spawn a real child, let it allocate real bytes, confirm
|
||||
``bytes_loaded`` tracks the kernel's VmRSS within a sane tolerance."""
|
||||
# Child that allocates ~100 MB of zero'd bytes and then idles.
|
||||
script = tmp_path / "burn.py"
|
||||
script.write_text(
|
||||
"import time, sys\n"
|
||||
"buf = bytearray(100 * 1024 * 1024)\n" # 100 MB
|
||||
"# touch every page so RSS actually grows\n"
|
||||
"for i in range(0, len(buf), 4096):\n"
|
||||
" buf[i] = 1\n"
|
||||
"sys.stdout.write('ready\\n')\n"
|
||||
"sys.stdout.flush()\n"
|
||||
"time.sleep(10)\n"
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(script)],
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
# Wait for the child to finish touching pages.
|
||||
ready = proc.stdout.readline()
|
||||
assert ready.strip() == b"ready"
|
||||
|
||||
# Create a fake 200 MB sparse gguf so bytes_total is concrete.
|
||||
gguf = tmp_path / "model.gguf"
|
||||
with open(gguf, "wb") as f:
|
||||
f.truncate(200 * 1024 * 1024)
|
||||
|
||||
inst = _make_backend(proc.pid, str(gguf), healthy = False)
|
||||
out = inst.load_progress()
|
||||
|
||||
assert out is not None, "load_progress returned None for live pid"
|
||||
assert out["phase"] == "mmap"
|
||||
assert out["bytes_total"] == 200 * 1024 * 1024
|
||||
# VmRSS for the Python child includes the interpreter + the 100MB
|
||||
# buffer, so a realistic floor is 50 MB and ceiling is 200 MB.
|
||||
assert (
|
||||
out["bytes_loaded"] >= 50 * 1024 * 1024
|
||||
), f"bytes_loaded unexpectedly low: {out['bytes_loaded']}"
|
||||
assert out["bytes_loaded"] <= 200 * 1024 * 1024
|
||||
assert 0.0 < out["fraction"] <= 1.0
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout = 5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
def test_live_ready_phase_when_healthy(tmp_path):
|
||||
gguf = tmp_path / "m.gguf"
|
||||
with open(gguf, "wb") as f:
|
||||
f.truncate(1 * 1024 * 1024)
|
||||
|
||||
inst = _make_backend(os.getpid(), str(gguf), healthy = True)
|
||||
out = inst.load_progress()
|
||||
assert out is not None
|
||||
assert out["phase"] == "ready"
|
||||
assert out["bytes_total"] == 1 * 1024 * 1024
|
||||
# Self-pid RSS is well above 1 MiB for CPython; fraction caps at 1.
|
||||
assert out["fraction"] == 1.0
|
||||
|
||||
|
||||
def test_live_dead_pid_returns_none(tmp_path):
|
||||
"""A recently-dead pid may linger in /proc for ms; use a clearly
|
||||
invalid id so the read reliably fails."""
|
||||
gguf = tmp_path / "m.gguf"
|
||||
gguf.touch()
|
||||
|
||||
inst = _make_backend(9_999_999_999, str(gguf), healthy = False)
|
||||
out = inst.load_progress()
|
||||
assert out is None
|
||||
|
||||
|
||||
def test_live_shard_aggregation_counts_real_files(tmp_path):
|
||||
"""With 4 real sibling shards on disk, ``bytes_total`` equals their
|
||||
summed size to the byte."""
|
||||
shard_size = 7 * 1024 * 1024 # 7 MB each
|
||||
for i in range(1, 5):
|
||||
f = tmp_path / f"model-{i:05d}-of-00004.gguf"
|
||||
with open(f, "wb") as fh:
|
||||
fh.truncate(shard_size)
|
||||
# Unrelated file in same dir -- must not be counted.
|
||||
with open(tmp_path / "config.json", "wb") as fh:
|
||||
fh.truncate(123)
|
||||
|
||||
inst = _make_backend(
|
||||
os.getpid(),
|
||||
str(tmp_path / "model-00001-of-00004.gguf"),
|
||||
healthy = False,
|
||||
)
|
||||
out = inst.load_progress()
|
||||
assert out is not None
|
||||
assert out["bytes_total"] == 4 * shard_size
|
||||
|
||||
|
||||
def test_live_repeated_polling_stays_sane(tmp_path):
|
||||
"""Sampling the same backend 20 times should not raise or produce
|
||||
non-numeric output, even under normal kernel RSS jitter."""
|
||||
gguf = tmp_path / "m.gguf"
|
||||
with open(gguf, "wb") as f:
|
||||
f.truncate(500 * 1024 * 1024)
|
||||
|
||||
inst = _make_backend(os.getpid(), str(gguf), healthy = False)
|
||||
seen = []
|
||||
for _ in range(20):
|
||||
out = inst.load_progress()
|
||||
assert out is not None
|
||||
assert isinstance(out["bytes_loaded"], int)
|
||||
assert isinstance(out["bytes_total"], int)
|
||||
assert 0.0 <= out["fraction"] <= 1.0
|
||||
seen.append(out["bytes_loaded"])
|
||||
time.sleep(0.01)
|
||||
# RSS of a healthy Python process doesn't go below ~5 MB.
|
||||
assert min(seen) > 1 * 1024 * 1024
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue