Merge branch 'main' into docker-blackwell-build
This commit is contained in:
commit
c3ba8c3801
68 changed files with 5692 additions and 644 deletions
377
.github/workflows/release-desktop.yml
vendored
377
.github/workflows/release-desktop.yml
vendored
|
|
@ -19,6 +19,19 @@ on:
|
|||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DESKTOP_RELEASE_NOTES: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> 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.
|
||||
|
||||
concurrency:
|
||||
group: release-desktop-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
|
@ -295,14 +308,6 @@ jobs:
|
|||
PY
|
||||
|
||||
build:
|
||||
# TODO: split into a "build (no secrets)" + "publish (secrets)" job pair
|
||||
# with actions/upload-artifact handoff so the matrix build cannot
|
||||
# publish a Release on its own. The current matrix runs across
|
||||
# Linux/macOS/Windows in a single job, so the split needs artefact
|
||||
# collection across the OS matrix and is out of scope for this
|
||||
# hardening pass.
|
||||
permissions:
|
||||
contents: write # tauri-apps/tauri-action creates / uploads a GitHub Release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
|
|
@ -311,15 +316,21 @@ jobs:
|
|||
- platform: macos-latest
|
||||
args: '--target aarch64-apple-darwin'
|
||||
label: macOS (Apple Silicon)
|
||||
artifact: macos-aarch64
|
||||
release_arch: aarch64
|
||||
# - platform: macos-latest
|
||||
# args: '--target x86_64-apple-darwin'
|
||||
# label: macOS (Intel)
|
||||
- platform: ubuntu-22.04
|
||||
args: ''
|
||||
label: Linux (x64)
|
||||
artifact: linux-x64
|
||||
release_arch: x64
|
||||
- platform: windows-latest
|
||||
args: ''
|
||||
label: Windows (x64)
|
||||
artifact: windows-x64
|
||||
release_arch: x64
|
||||
|
||||
name: Build ${{ matrix.label }}
|
||||
needs: prepare-version
|
||||
|
|
@ -465,41 +476,18 @@ jobs:
|
|||
if (chmodIdx !== -1 && sha256Idx > chmodIdx) {
|
||||
throw new Error('Desktop Linux release must verify the linuxdeploy digest before chmod +x');
|
||||
}
|
||||
const releaseBodies = [];
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const match = lines[i].match(/^(\s*)releaseBody:\s*\|\s*$/);
|
||||
if (!match) continue;
|
||||
const baseIndent = match[1].length;
|
||||
const bodyLines = [];
|
||||
i += 1;
|
||||
for (; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') {
|
||||
bodyLines.push('');
|
||||
continue;
|
||||
}
|
||||
const indent = line.match(/^\s*/)[0].length;
|
||||
if (indent <= baseIndent) {
|
||||
i -= 1;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(line.slice(baseIndent + 2));
|
||||
}
|
||||
releaseBodies.push(bodyLines.join('\n'));
|
||||
const releaseBody = process.env.DESKTOP_RELEASE_NOTES;
|
||||
if (!releaseBody) {
|
||||
throw new Error('DESKTOP_RELEASE_NOTES must not be empty');
|
||||
}
|
||||
if (releaseBodies.length === 0) {
|
||||
throw new Error('Expected at least one desktop release body');
|
||||
if (/\brpm\b|\.rpm/i.test(releaseBody)) {
|
||||
throw new Error('Desktop release body must not advertise RPM packages');
|
||||
}
|
||||
for (const body of releaseBodies) {
|
||||
if (/\brpm\b|\.rpm/i.test(body)) {
|
||||
throw new Error('Desktop release body must not advertise RPM packages');
|
||||
}
|
||||
if (/AppImage.*universal|universal.*AppImage/i.test(body)) {
|
||||
throw new Error('Desktop release body must not advertise AppImage as universal');
|
||||
}
|
||||
if (!/AppImage.*experimental/i.test(body)) {
|
||||
throw new Error('Desktop release body must mark AppImage as experimental');
|
||||
}
|
||||
if (/AppImage.*universal|universal.*AppImage/i.test(releaseBody)) {
|
||||
throw new Error('Desktop release body must not advertise AppImage as universal');
|
||||
}
|
||||
if (!/AppImage.*experimental/i.test(releaseBody)) {
|
||||
throw new Error('Desktop release body must mark AppImage as experimental');
|
||||
}
|
||||
JS
|
||||
|
||||
|
|
@ -644,48 +632,33 @@ jobs:
|
|||
dest="$tools_dir/linuxdeploy-x86_64.AppImage"
|
||||
curl -fsSL "$LINUXDEPLOY_URL" -o "$dest"
|
||||
# Verify the digest BEFORE the binary is ever marked executable. The
|
||||
# next step builds the AppImage with the Tauri signing key and a
|
||||
# contents:write GITHUB_TOKEN in scope, so a substituted linuxdeploy
|
||||
# that ran here could exfiltrate signing material or tamper with
|
||||
# published release artifacts. Fail closed on any mismatch.
|
||||
# next step builds the AppImage with the Tauri signing key, so a
|
||||
# substituted linuxdeploy that ran here could exfiltrate signing
|
||||
# material or tamper with release artifacts. Fail closed on any
|
||||
# mismatch.
|
||||
echo "${LINUXDEPLOY_SHA256} ${dest}" | sha256sum -c -
|
||||
chmod +x "$dest"
|
||||
|
||||
# ── Linux: build + sign + upload ──
|
||||
# ── Linux: build + sign ──
|
||||
- name: Build Linux app
|
||||
id: build_linux
|
||||
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 }}
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/tauri-tools-cache
|
||||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# ── macOS: build + sign + notarize + upload ──
|
||||
# ── macOS: build + sign + notarize ──
|
||||
- name: Build macOS app
|
||||
id: build_macos
|
||||
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 }}
|
||||
|
|
@ -695,29 +668,14 @@ jobs:
|
|||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# ── Windows: build + sign + upload ──
|
||||
# ── Windows: build + sign ──
|
||||
- name: Build Windows app
|
||||
id: build_windows
|
||||
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 }}
|
||||
|
|
@ -728,35 +686,83 @@ jobs:
|
|||
with:
|
||||
projectPath: studio
|
||||
tauriScript: npx --prefix . tauri
|
||||
tagName: ${{ needs.prepare-version.outputs.desktop_release_tag }}
|
||||
releaseName: 'Unsloth Studio (Desktop) ${{ needs.prepare-version.outputs.studio_version }}'
|
||||
releaseBody: |
|
||||
Desktop app for Unsloth Studio.
|
||||
|
||||
**macOS**: Download the Apple Silicon `.dmg`.
|
||||
**Windows**: Download the `-setup.exe` installer.
|
||||
**Linux**: Download `.deb` for Ubuntu/Debian. `.AppImage` is experimental.
|
||||
|
||||
> Linux in-app updates are AppImage-oriented. Package installs should update by downloading a new package.
|
||||
> Linux AppImage can show a blank window on some Tauri/WebKitGTK + Wayland/Mesa stacks; use `.deb` when available.
|
||||
> Linux AppImage on Ubuntu 24.04+ may require: `sudo apt install libfuse2t64`
|
||||
> First-run system dependency elevation is supported on Ubuntu/Debian. Other Linux distributions should install system packages manually.
|
||||
releaseDraft: ${{ inputs.draft }}
|
||||
prerelease: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
args: -v ${{ matrix.args }}
|
||||
|
||||
# Release process note: only non-draft workflow runs advance the public
|
||||
# desktop-latest updater channel. Draft builds are for private review; if a
|
||||
# draft is manually published later, this channel intentionally remains
|
||||
# unchanged until a narrow manual channel-publish flow is added or a public
|
||||
# desktop release is created by running this workflow with draft=false.
|
||||
publish-updater-channel:
|
||||
name: Publish desktop updater channel
|
||||
- name: Stage release assets
|
||||
shell: bash
|
||||
env:
|
||||
ARTIFACT_PATHS: ${{ steps.build_linux.outputs.artifactPaths || steps.build_macos.outputs.artifactPaths || steps.build_windows.outputs.artifactPaths }}
|
||||
RELEASE_ARCH: ${{ matrix.release_arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PYTHON=python3
|
||||
else
|
||||
PYTHON=python
|
||||
fi
|
||||
"$PYTHON" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
raw_paths = os.environ.get('ARTIFACT_PATHS', '')
|
||||
try:
|
||||
artifact_paths = json.loads(raw_paths)
|
||||
except json.JSONDecodeError as error:
|
||||
sys.exit(f'Invalid tauri-action artifactPaths output: {error}')
|
||||
if not isinstance(artifact_paths, list) or not artifact_paths:
|
||||
sys.exit('tauri-action did not return any release artifacts')
|
||||
|
||||
destination = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
staged = []
|
||||
for raw_path in artifact_paths:
|
||||
source = pathlib.Path(raw_path)
|
||||
if not source.is_file():
|
||||
continue
|
||||
name = source.name
|
||||
for extension in ('.app.tar.gz.sig', '.app.tar.gz'):
|
||||
if name.endswith(extension):
|
||||
name = f'{name[:-len(extension)]}_{os.environ["RELEASE_ARCH"]}{extension}'
|
||||
break
|
||||
name = unicodedata.normalize('NFD', name)
|
||||
name = ''.join(character for character in name if not unicodedata.combining(character))
|
||||
name = re.sub(r'[ ()\[\]{}]', '.', name)
|
||||
while '..' in name:
|
||||
name = name.replace('..', '.')
|
||||
target = destination / name
|
||||
if target.exists():
|
||||
sys.exit(f'Duplicate staged release asset name: {name}')
|
||||
shutil.copy2(source, target)
|
||||
staged.append(name)
|
||||
|
||||
if not staged:
|
||||
sys.exit('No release files were staged')
|
||||
print('Staged release assets:')
|
||||
print('\n'.join(sorted(staged)))
|
||||
PY
|
||||
|
||||
- name: Upload signed release assets
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: desktop-release-${{ matrix.artifact }}
|
||||
path: ${{ runner.temp }}/desktop-release-assets/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 1
|
||||
|
||||
# Only this job gets write access; builds hand off signed files via artifacts.
|
||||
# Draft runs do not advance the public desktop-latest channel.
|
||||
publish-release:
|
||||
name: Publish desktop release
|
||||
needs: [prepare-version, build]
|
||||
if: ${{ !inputs.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
contents: write # create the versioned Release and replace updater-channel metadata
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
APP_VERSION: ${{ needs.prepare-version.outputs.app_version }}
|
||||
|
|
@ -765,7 +771,164 @@ jobs:
|
|||
DESKTOP_PRERELEASE: ${{ needs.prepare-version.outputs.prerelease }}
|
||||
|
||||
steps:
|
||||
- name: Harden runner (audit)
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Download signed release assets
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: desktop-release-*
|
||||
path: ${{ runner.temp }}/desktop-release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Validate release asset set
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 <<'PY'
|
||||
import pathlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
||||
files = [path for path in asset_dir.iterdir() if path.is_file()]
|
||||
required_suffixes = (
|
||||
'.dmg',
|
||||
'.app.tar.gz',
|
||||
'.app.tar.gz.sig',
|
||||
'.deb',
|
||||
'.AppImage',
|
||||
'.AppImage.sig',
|
||||
'-setup.exe',
|
||||
'-setup.exe.sig',
|
||||
)
|
||||
for suffix in required_suffixes:
|
||||
matches = [path for path in files if path.name.endswith(suffix)]
|
||||
if len(matches) != 1:
|
||||
sys.exit(f'Expected exactly one {suffix} release asset, found {len(matches)}')
|
||||
if any(path.name == 'latest.json' for path in files):
|
||||
sys.exit('Build artifacts must not supply latest.json')
|
||||
print('\n'.join(sorted(path.name for path in files)))
|
||||
PY
|
||||
|
||||
- name: Create or validate versioned release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_DRAFT: ${{ inputs.draft }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
notes_file="$RUNNER_TEMP/desktop-release-notes.md"
|
||||
printf '%s\n' "$DESKTOP_RELEASE_NOTES" > "$notes_file"
|
||||
|
||||
release_json="$RUNNER_TEMP/versioned-release.json"
|
||||
# REST tag lookup omits drafts; `gh release view` also checks pending tags.
|
||||
if gh release view "$DESKTOP_RELEASE_TAG" \
|
||||
--json tagName,isDraft,isPrerelease > "$release_json" 2>/dev/null; then
|
||||
python3 <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
release = json.loads(pathlib.Path(os.environ['RUNNER_TEMP'], 'versioned-release.json').read_text())
|
||||
expected_draft = os.environ['RELEASE_DRAFT'].lower() == 'true'
|
||||
expected_prerelease = os.environ['DESKTOP_PRERELEASE'].lower() == 'true'
|
||||
if release.get('tagName') != os.environ['DESKTOP_RELEASE_TAG']:
|
||||
sys.exit('Existing desktop release tag does not match the requested tag')
|
||||
if bool(release.get('isDraft')) != expected_draft:
|
||||
sys.exit('Existing desktop release draft state does not match the workflow input')
|
||||
if bool(release.get('isPrerelease')) != expected_prerelease:
|
||||
sys.exit('Existing desktop release prerelease state does not match the requested version')
|
||||
PY
|
||||
else
|
||||
release_flags=(
|
||||
--title "Unsloth Studio (Desktop) ${STUDIO_VERSION}"
|
||||
--notes-file "$notes_file"
|
||||
--target "$GITHUB_SHA"
|
||||
)
|
||||
if [ "$RELEASE_DRAFT" = "true" ]; then
|
||||
release_flags+=(--draft)
|
||||
fi
|
||||
if [ "$DESKTOP_PRERELEASE" = "true" ]; then
|
||||
release_flags+=(--prerelease)
|
||||
fi
|
||||
gh release create "$DESKTOP_RELEASE_TAG" "${release_flags[@]}"
|
||||
fi
|
||||
|
||||
- name: Publish versioned release assets
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/desktop-release-assets"/* --clobber
|
||||
|
||||
- name: Generate and publish versioned updater metadata
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 <<'PY'
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
asset_dir = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-assets')
|
||||
files = [path for path in asset_dir.iterdir() if path.is_file()]
|
||||
|
||||
def exactly_one(suffix: str) -> pathlib.Path:
|
||||
matches = [path for path in files if path.name.endswith(suffix)]
|
||||
if len(matches) != 1:
|
||||
sys.exit(f'Expected exactly one {suffix} updater asset, found {len(matches)}')
|
||||
return matches[0]
|
||||
|
||||
def entry(signature_suffix: str) -> dict[str, str]:
|
||||
signature_path = exactly_one(signature_suffix)
|
||||
bundle_name = signature_path.name.removesuffix('.sig')
|
||||
bundle_path = asset_dir / bundle_name
|
||||
if not bundle_path.is_file():
|
||||
sys.exit(f'Missing updater bundle for {signature_path.name}: {bundle_name}')
|
||||
encoded_tag = urllib.parse.quote(os.environ['DESKTOP_RELEASE_TAG'], safe='')
|
||||
encoded_name = urllib.parse.quote(bundle_name, safe='')
|
||||
return {
|
||||
'signature': signature_path.read_text(),
|
||||
'url': (
|
||||
f'https://github.com/{os.environ["GITHUB_REPOSITORY"]}/releases/download/'
|
||||
f'{encoded_tag}/{encoded_name}'
|
||||
),
|
||||
}
|
||||
|
||||
darwin = entry('.app.tar.gz.sig')
|
||||
linux = entry('.AppImage.sig')
|
||||
windows = entry('.exe.sig')
|
||||
notes = pathlib.Path(os.environ['RUNNER_TEMP'], 'desktop-release-notes.md').read_text()
|
||||
metadata = {
|
||||
'version': os.environ['APP_VERSION'],
|
||||
'notes': notes,
|
||||
'pub_date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||
'platforms': {
|
||||
'darwin-aarch64': darwin,
|
||||
'darwin-aarch64-app': darwin,
|
||||
'linux-x86_64': linux,
|
||||
'linux-x86_64-appimage': linux,
|
||||
'windows-x86_64': windows,
|
||||
'windows-x86_64-nsis': windows,
|
||||
},
|
||||
}
|
||||
output = pathlib.Path(os.environ['RUNNER_TEMP'], 'latest.json')
|
||||
output.write_text(json.dumps(metadata, indent=2) + '\n')
|
||||
PY
|
||||
gh release upload "$DESKTOP_RELEASE_TAG" "$RUNNER_TEMP/latest.json" --clobber
|
||||
|
||||
- name: Download versioned updater metadata
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -790,6 +953,7 @@ jobs:
|
|||
test -s "$RUNNER_TEMP/desktop-updater/latest.json"
|
||||
|
||||
- name: Validate versioned updater metadata
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
|
|
@ -849,6 +1013,7 @@ jobs:
|
|||
PY
|
||||
|
||||
- name: Ensure desktop updater channel release
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -881,6 +1046,7 @@ jobs:
|
|||
PY
|
||||
|
||||
- name: Prevent updater channel downgrade
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
@ -971,6 +1137,7 @@ jobs:
|
|||
PY
|
||||
|
||||
- name: Publish desktop updater channel metadata
|
||||
if: ${{ !inputs.draft }}
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
|
|
|||
2
.github/workflows/studio-inference-smoke.yml
vendored
2
.github/workflows/studio-inference-smoke.yml
vendored
|
|
@ -729,6 +729,7 @@ jobs:
|
|||
content, events = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": enabled,
|
||||
"session_id": f"{session}-att{attempt_i}",
|
||||
"temperature": TOOL_PROBE_TEMP,
|
||||
|
|
@ -818,6 +819,7 @@ jobs:
|
|||
content, events = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["web_search"],
|
||||
"session_id": "ci-tool-calling-web",
|
||||
"temperature": 0.0,
|
||||
|
|
|
|||
|
|
@ -612,6 +612,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["python"],
|
||||
"session_id": "ci-tool-calling-py",
|
||||
"temperature": TEMP,
|
||||
|
|
@ -647,6 +648,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["web_search"],
|
||||
"session_id": "ci-tool-calling-web",
|
||||
"temperature": TEMP,
|
||||
|
|
|
|||
|
|
@ -791,6 +791,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "What is 123 * 456? Use the python tool to compute it and tell me the number."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["python"],
|
||||
"session_id": "ci-tool-calling-py",
|
||||
"temperature": TEMP,
|
||||
|
|
@ -816,6 +817,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Use the terminal tool to run `echo hello-bash-tool` and tell me the exact output."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["terminal"],
|
||||
"session_id": "ci-tool-calling-bash",
|
||||
"temperature": TEMP,
|
||||
|
|
@ -840,6 +842,7 @@ jobs:
|
|||
content = post_sse("/v1/chat/completions", {
|
||||
"messages": [{"role": "user", "content": "Search the web for 'unsloth ai github' and summarise."}],
|
||||
"enable_tools": True,
|
||||
"permission_mode": "full",
|
||||
"enabled_tools": ["web_search"],
|
||||
"session_id": "ci-tool-calling-web",
|
||||
"temperature": TEMP,
|
||||
|
|
|
|||
34
studio/MCP.md
Normal file
34
studio/MCP.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Unsloth Studio MCP server
|
||||
|
||||
Studio can expose a local MCP server so an MCP client can inspect models and
|
||||
GPU state, validate recipes, start or stop training, inspect recipe output, and
|
||||
export a loaded model.
|
||||
|
||||
The server is disabled by default. Enable it for a local Studio process with:
|
||||
|
||||
```bash
|
||||
UNSLOTH_STUDIO_ENABLE_MCP=1 \
|
||||
UNSLOTH_STUDIO_MCP_TOKEN='use-a-local-secret' \
|
||||
unsloth studio
|
||||
```
|
||||
|
||||
The endpoint is `http://127.0.0.1:8888/mcp/` when Studio uses its default port
|
||||
(a request to `/mcp` redirects to the canonical `/mcp/`). Use the actual Studio
|
||||
port when it is configured differently.
|
||||
|
||||
The high-impact tools are:
|
||||
|
||||
- `studio_status` and `list_local_models` for discovery
|
||||
- `get_training_status`, `start_training`, `stop_training`, and `list_training_runs`
|
||||
- `validate_recipe`, `get_recipe_job_status`, and `get_recipe_job_dataset`
|
||||
- `load_checkpoint` and `export_gguf`
|
||||
|
||||
`start_training` accepts the same fields as the Studio `TrainingStartRequest`.
|
||||
The request is validated by the existing Pydantic model before a subprocess is
|
||||
started. Export paths use the existing Studio validation as well.
|
||||
|
||||
The endpoint always requires `UNSLOTH_STUDIO_MCP_TOKEN` and checks an exact
|
||||
Bearer token for both HTTP and WebSocket connections. Keep it on localhost
|
||||
unless the deployment has an authenticated reverse proxy. The MCP endpoint is
|
||||
intentionally opt-in because tools can consume GPU memory, write model
|
||||
artifacts, and stop active work.
|
||||
|
|
@ -10,10 +10,242 @@ native-chat-template fallback used by the transformers and MLX backends.
|
|||
import copy
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
_THINK_OPEN = "<think>"
|
||||
_THINK_CLOSE = "</think>"
|
||||
_GEMMA_CHANNEL_START = "<|channel>"
|
||||
_GEMMA_THOUGHT_OPEN = "<|channel>thought"
|
||||
_GEMMA_THOUGHT_CLOSE = "<channel|>"
|
||||
_GEMMA_TEMPLATE_OPENERS = (
|
||||
_GEMMA_THOUGHT_OPEN + "\n",
|
||||
_GEMMA_THOUGHT_OPEN + "\\n",
|
||||
_GEMMA_THOUGHT_OPEN + _GEMMA_THOUGHT_CLOSE,
|
||||
)
|
||||
|
||||
|
||||
def _tokenizer_objects(tokenizer) -> tuple:
|
||||
"""Return a processor/tokenizer and its distinct nested tokenizer."""
|
||||
if tokenizer is None:
|
||||
return ()
|
||||
nested = getattr(tokenizer, "tokenizer", None)
|
||||
return (tokenizer,) if nested is None or nested is tokenizer else (tokenizer, nested)
|
||||
|
||||
|
||||
def _selected_template_strings_from_value(
|
||||
template,
|
||||
tools = None,
|
||||
*,
|
||||
prefer_tool_use: bool = True,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return the named chat template matching HF's default selection rules."""
|
||||
tools = tools or None
|
||||
if isinstance(template, str):
|
||||
return (template,)
|
||||
if not isinstance(template, dict):
|
||||
return ()
|
||||
if prefer_tool_use and tools and isinstance(template.get("tool_use"), str):
|
||||
return (template["tool_use"],)
|
||||
if isinstance(template.get("default"), str):
|
||||
return (template["default"],)
|
||||
values = tuple(value for value in template.values() if isinstance(value, str))
|
||||
return values if len(values) == 1 else ()
|
||||
|
||||
|
||||
def _selected_chat_template_strings(tokenizer, tools = None) -> tuple[str, ...]:
|
||||
"""Return the active chat template selected for this request."""
|
||||
tools = tools or None
|
||||
getter = getattr(tokenizer, "get_chat_template", None)
|
||||
if callable(getter):
|
||||
for kwargs in ({"chat_template": None, "tools": tools}, {"tools": tools}, {}):
|
||||
try:
|
||||
selected = getter(**kwargs)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(selected, str):
|
||||
return (selected,)
|
||||
# ProcessorMixin.apply_chat_template does not switch to "tool_use" implicitly;
|
||||
# it uses "default" unless chat_template= names another template.
|
||||
is_processor = getattr(tokenizer, "tokenizer", None) is not None and callable(
|
||||
getattr(tokenizer, "apply_chat_template", None)
|
||||
)
|
||||
return _selected_template_strings_from_value(
|
||||
getattr(tokenizer, "chat_template", None),
|
||||
tools,
|
||||
prefer_tool_use = not is_processor,
|
||||
)
|
||||
|
||||
|
||||
def _detect_reasoning_channel_markers_from_templates(
|
||||
templates: tuple[str, ...],
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Return Gemma native reasoning markers only when a template emits them."""
|
||||
if any(opener in template for template in templates for opener in _GEMMA_TEMPLATE_OPENERS):
|
||||
return _GEMMA_THOUGHT_OPEN, _GEMMA_THOUGHT_CLOSE
|
||||
return None
|
||||
|
||||
|
||||
def detect_reasoning_channel_markers(tokenizer, tools = None) -> Optional[tuple[str, str]]:
|
||||
"""Return native Gemma thought-channel markers supported by a tokenizer.
|
||||
|
||||
Detection uses the active chat template rather than model names or vocabulary
|
||||
membership. Some models expose Gemma control tokens without using the native
|
||||
thought-channel response protocol, and those must keep normal
|
||||
``skip_special_tokens`` streaming.
|
||||
"""
|
||||
for obj in _tokenizer_objects(tokenizer):
|
||||
templates = _selected_chat_template_strings(obj, tools)
|
||||
if templates:
|
||||
return _detect_reasoning_channel_markers_from_templates(templates)
|
||||
return None
|
||||
|
||||
|
||||
def detect_reasoning_channel_markers_from_template(
|
||||
template, tools = None
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Return native Gemma thought-channel markers from a raw template value."""
|
||||
return _detect_reasoning_channel_markers_from_templates(
|
||||
_selected_template_strings_from_value(template, tools)
|
||||
)
|
||||
|
||||
|
||||
def detect_reasoning_channel_markers_from_model_info(
|
||||
tokenizer,
|
||||
model_info: Optional[dict] = None,
|
||||
tools = None,
|
||||
) -> Optional[tuple[str, str]]:
|
||||
"""Return reasoning markers from the active or cached native template."""
|
||||
markers = detect_reasoning_channel_markers(tokenizer, tools = tools)
|
||||
if markers is not None or not isinstance(model_info, dict):
|
||||
return markers
|
||||
|
||||
native_templates = (
|
||||
model_info.get("native_chat_template"),
|
||||
(model_info.get("chat_template_info") or {}).get("template"),
|
||||
)
|
||||
for template in native_templates:
|
||||
markers = detect_reasoning_channel_markers_from_template(template, tools)
|
||||
if markers is not None:
|
||||
return markers
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen = True)
|
||||
class ChatTemplateRenderResult:
|
||||
"""Prompt plus response-protocol metadata selected by the renderer."""
|
||||
|
||||
prompt: str
|
||||
reasoning_channel_markers: Optional[tuple[str, str]] = None
|
||||
|
||||
|
||||
def _split_partial_marker(text: str, marker: str) -> tuple[str, str]:
|
||||
"""Hold the longest suffix that may become ``marker`` in the next chunk."""
|
||||
for length in range(min(len(text), len(marker) - 1), 0, -1):
|
||||
if text.endswith(marker[:length]):
|
||||
return text[:-length], text[-length:]
|
||||
return text, ""
|
||||
|
||||
|
||||
class ReasoningChannelNormalizer:
|
||||
"""Incrementally convert one native reasoning channel to ``<think>``.
|
||||
|
||||
The parser follows mlx-vlm's streaming boundary behavior but emits Studio's
|
||||
established canonical text contract. Only the configured opening and
|
||||
closing markers are consumed; tool-call and other control markers remain
|
||||
available to downstream parsers.
|
||||
"""
|
||||
|
||||
def __init__(self, opening_marker: str, closing_marker: str):
|
||||
self._opening_marker = opening_marker
|
||||
self._closing_marker = closing_marker
|
||||
self._buffer = ""
|
||||
self._in_reasoning = False
|
||||
self._reasoning_done = False
|
||||
self._skip_opening_newline = False
|
||||
|
||||
def feed(self, text: str) -> str:
|
||||
"""Consume a raw text delta and return the stable canonical delta."""
|
||||
self._buffer += text or ""
|
||||
output: list[str] = []
|
||||
while self._buffer:
|
||||
if self._reasoning_done:
|
||||
output.append(self._buffer)
|
||||
self._buffer = ""
|
||||
break
|
||||
|
||||
if self._in_reasoning and self._skip_opening_newline:
|
||||
if self._buffer.startswith("\n"):
|
||||
self._buffer = self._buffer[1:]
|
||||
self._skip_opening_newline = False
|
||||
if not self._buffer:
|
||||
break
|
||||
|
||||
marker = self._closing_marker if self._in_reasoning else self._opening_marker
|
||||
index = self._buffer.find(marker)
|
||||
if index < 0:
|
||||
stable, self._buffer = _split_partial_marker(self._buffer, marker)
|
||||
output.append(stable)
|
||||
break
|
||||
|
||||
output.append(self._buffer[:index])
|
||||
self._buffer = self._buffer[index + len(marker) :]
|
||||
if self._in_reasoning:
|
||||
output.append(_THINK_CLOSE)
|
||||
self._in_reasoning = False
|
||||
self._reasoning_done = True
|
||||
else:
|
||||
output.append(_THINK_OPEN)
|
||||
self._in_reasoning = True
|
||||
self._skip_opening_newline = True
|
||||
return "".join(output)
|
||||
|
||||
def finish(self) -> str:
|
||||
"""Flush a naturally completed stream and close an open think block."""
|
||||
output = self.drain()
|
||||
if self._in_reasoning:
|
||||
output += _THINK_CLOSE
|
||||
self._in_reasoning = False
|
||||
self._reasoning_done = True
|
||||
return output
|
||||
|
||||
def drain(self) -> str:
|
||||
"""Flush buffered literal text without synthesizing a closing tag."""
|
||||
output = self._buffer
|
||||
self._buffer = ""
|
||||
return output
|
||||
|
||||
|
||||
def normalize_reasoning_snapshots(
|
||||
stream,
|
||||
tokenizer = None,
|
||||
cancel_event = None,
|
||||
markers: Optional[tuple[str, str]] = None,
|
||||
tools = None,
|
||||
):
|
||||
"""Normalize a prefix-monotonic cumulative text stream when supported."""
|
||||
markers = markers or detect_reasoning_channel_markers(tokenizer, tools = tools)
|
||||
if markers is None:
|
||||
yield from stream
|
||||
return
|
||||
|
||||
normalizer = ReasoningChannelNormalizer(*markers)
|
||||
raw_output = ""
|
||||
normalized_output = ""
|
||||
for snapshot in stream:
|
||||
if not snapshot.startswith(raw_output):
|
||||
raise RuntimeError("Reasoning normalization requires cumulative text snapshots")
|
||||
delta = normalizer.feed(snapshot[len(raw_output) :])
|
||||
raw_output = snapshot
|
||||
if delta:
|
||||
normalized_output += delta
|
||||
yield normalized_output
|
||||
|
||||
cancelled = cancel_event is not None and cancel_event.is_set()
|
||||
tail = normalizer.drain() if cancelled else normalizer.finish()
|
||||
if tail:
|
||||
normalized_output += tail
|
||||
yield normalized_output
|
||||
|
||||
|
||||
def detect_think_prefill(prompt: Optional[str], special_tokens = None) -> str:
|
||||
|
|
@ -166,7 +398,8 @@ def render_native_template(
|
|||
preserve_thinking: Optional[bool] = None,
|
||||
apply_fn = None,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
return_metadata: bool = False,
|
||||
):
|
||||
"""Render ``messages`` + ``tools`` with the model's NATIVE chat template.
|
||||
|
||||
Some Unsloth override templates (e.g. ``mistral``, ``gemma-4``) do not emit
|
||||
|
|
@ -175,7 +408,9 @@ def render_native_template(
|
|||
tool-calling syntax. It is loaded straight from the repo (bypassing any
|
||||
override on the live tokenizer) and cached on ``model_info``. Returns the
|
||||
rendered prompt only if the native template actually emits the tools (render
|
||||
differs with vs without tools); otherwise ``None``.
|
||||
differs with vs without tools); otherwise ``None``. With ``return_metadata``,
|
||||
returns ``ChatTemplateRenderResult`` so callers can stream with the response
|
||||
protocol selected by this request's template.
|
||||
|
||||
``hf_token`` is the token the model was loaded with -- passed to the repo load
|
||||
so a gated/private model's native template can still be fetched (otherwise the
|
||||
|
|
@ -261,7 +496,16 @@ def render_native_template(
|
|||
exc,
|
||||
)
|
||||
return None
|
||||
return with_tools if with_tools != no_tools else None
|
||||
if with_tools == no_tools:
|
||||
return None
|
||||
if return_metadata:
|
||||
return ChatTemplateRenderResult(
|
||||
with_tools,
|
||||
_detect_reasoning_channel_markers_from_templates(
|
||||
_selected_template_strings_from_value(native_tpl, tools)
|
||||
),
|
||||
)
|
||||
return with_tools
|
||||
|
||||
|
||||
def render_with_native_template_fallback(
|
||||
|
|
@ -277,7 +521,8 @@ def render_with_native_template_fallback(
|
|||
preserve_thinking: Optional[bool] = None,
|
||||
apply_fn = None,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> str:
|
||||
return_metadata: bool = False,
|
||||
):
|
||||
"""Return ``formatted_prompt``, swapping in a native-template render when an
|
||||
override template dropped the ``tools`` schema.
|
||||
|
||||
|
|
@ -285,9 +530,27 @@ def render_with_native_template_fallback(
|
|||
them (detected by comparison, robust against tool names in the system prompt),
|
||||
re-render with the model's native template. Shared by the transformers and MLX
|
||||
backends so both advertise tools consistently. ``hf_token`` is forwarded so a
|
||||
gated/private model's native template can still be fetched."""
|
||||
gated/private model's native template can still be fetched. With
|
||||
``return_metadata``, returns the selected prompt plus reasoning-channel markers
|
||||
for the exact template used by this request."""
|
||||
live_markers = detect_reasoning_channel_markers(tokenizer, tools = tools)
|
||||
|
||||
def _result(prompt: str, markers = live_markers):
|
||||
if return_metadata:
|
||||
return ChatTemplateRenderResult(prompt, markers)
|
||||
return prompt
|
||||
|
||||
if not tools:
|
||||
return formatted_prompt
|
||||
# Gemma 4 can emit its native reasoning protocol even when a generation-time
|
||||
# Unsloth override rendered a marker-free prompt. Preserve the live-verified
|
||||
# no-tools thinking behavior without letting cached native metadata describe
|
||||
# unrelated tool prompts that kept the active override.
|
||||
markers = live_markers
|
||||
if markers is None:
|
||||
markers = detect_reasoning_channel_markers_from_model_info(
|
||||
tokenizer, model_info, tools = None
|
||||
)
|
||||
return _result(formatted_prompt, markers)
|
||||
if apply_fn is None:
|
||||
apply_fn = apply_chat_template_for_generation
|
||||
# Probe whether the live template dropped the schema. A tools-requiring template
|
||||
|
|
@ -307,9 +570,9 @@ def render_with_native_template_fallback(
|
|||
active_model_name,
|
||||
exc,
|
||||
)
|
||||
return formatted_prompt
|
||||
return _result(formatted_prompt)
|
||||
if formatted_prompt != probe_no_tools:
|
||||
return formatted_prompt # template already emits the tools schema
|
||||
return _result(formatted_prompt) # template already emits the tools schema
|
||||
native_prompt = render_native_template(
|
||||
model_info = model_info,
|
||||
active_model_name = active_model_name,
|
||||
|
|
@ -320,6 +583,7 @@ def render_with_native_template_fallback(
|
|||
preserve_thinking = preserve_thinking,
|
||||
apply_fn = apply_fn,
|
||||
hf_token = hf_token,
|
||||
return_metadata = return_metadata,
|
||||
)
|
||||
if native_prompt:
|
||||
logger.info(
|
||||
|
|
@ -328,4 +592,4 @@ def render_with_native_template_fallback(
|
|||
active_model_name,
|
||||
)
|
||||
return native_prompt
|
||||
return formatted_prompt
|
||||
return _result(formatted_prompt)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
from unsloth import FastLanguageModel, FastVisionModel
|
||||
from unsloth.chat_templates import get_chat_template
|
||||
from transformers import TextStreamer
|
||||
from transformers import TextIteratorStreamer, TextStreamer
|
||||
from peft import PeftModel, PeftModelForCausalLM
|
||||
|
||||
import json
|
||||
|
|
@ -32,6 +32,11 @@ from core.inference.chat_eos import (
|
|||
chat_eos_repair,
|
||||
resolve_chat_turn_end_eos_ids_using,
|
||||
)
|
||||
from core.inference.chat_template_helpers import (
|
||||
ReasoningChannelNormalizer,
|
||||
detect_reasoning_channel_markers,
|
||||
detect_think_prefill,
|
||||
)
|
||||
from core.inference.presence_penalty import _make_presence_penalty_processor
|
||||
from io import StringIO
|
||||
import structlog
|
||||
|
|
@ -187,6 +192,53 @@ class HarmonyTextStreamer:
|
|||
self._queue.put(new_content)
|
||||
|
||||
|
||||
class ReasoningTextIteratorStreamer(TextIteratorStreamer):
|
||||
"""TextIteratorStreamer that preserves native channel tokens until parsed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer,
|
||||
*,
|
||||
markers: tuple[str, str],
|
||||
skip_prompt: bool = True,
|
||||
timeout: float = 0.2,
|
||||
cancel_event = None,
|
||||
**decode_kwargs,
|
||||
):
|
||||
decode_kwargs["skip_special_tokens"] = False
|
||||
super().__init__(tokenizer, skip_prompt = skip_prompt, timeout = timeout, **decode_kwargs)
|
||||
self._normalizer = ReasoningChannelNormalizer(*markers)
|
||||
self._cancel_event = cancel_event
|
||||
self._aborted = False
|
||||
|
||||
def abort(self):
|
||||
"""Mark generation as failed so ``end`` drains without closing."""
|
||||
self._aborted = True
|
||||
|
||||
def on_finalized_text(
|
||||
self,
|
||||
text: str,
|
||||
stream_end: bool = False,
|
||||
):
|
||||
"""Queue canonical deltas, closing only on natural stream completion."""
|
||||
delta = self._normalizer.feed(text)
|
||||
if delta:
|
||||
self.text_queue.put(delta, timeout = self.timeout)
|
||||
|
||||
if stream_end:
|
||||
cancelled = self._aborted or (
|
||||
self._cancel_event is not None and self._cancel_event.is_set()
|
||||
)
|
||||
tail = self._normalizer.drain() if cancelled else self._normalizer.finish()
|
||||
if tail:
|
||||
self.text_queue.put(tail, timeout = self.timeout)
|
||||
self.text_queue.put(self.stop_signal, timeout = self.timeout)
|
||||
|
||||
|
||||
class _GenerationThreadError(RuntimeError):
|
||||
"""Generation worker failures that should propagate through stream routes."""
|
||||
|
||||
|
||||
class InferenceBackend:
|
||||
"""Unified inference backend supporting text, vision, and LoRA models"""
|
||||
|
||||
|
|
@ -836,6 +888,7 @@ class InferenceBackend:
|
|||
thread_id: Optional[str] = None,
|
||||
rag_scope: Optional[dict] = None,
|
||||
presence_penalty: float = 0.0,
|
||||
reasoning_prefilled: bool = False,
|
||||
):
|
||||
"""Run an agentic tool loop on top of ``generate_chat_response``.
|
||||
|
||||
|
|
@ -889,6 +942,7 @@ class InferenceBackend:
|
|||
session_id = session_id,
|
||||
thread_id = thread_id,
|
||||
rag_scope = rag_scope,
|
||||
reasoning_prefilled = reasoning_prefilled,
|
||||
)
|
||||
|
||||
def generate_chat_response(
|
||||
|
|
@ -960,8 +1014,7 @@ class InferenceBackend:
|
|||
thread can toggle adapters under the generation lock.
|
||||
"""
|
||||
if not self.active_model_name:
|
||||
yield "Error: No active model"
|
||||
return
|
||||
raise RuntimeError("No active model")
|
||||
|
||||
model_info = self.models[self.active_model_name]
|
||||
is_vision = model_info.get("is_vision", False)
|
||||
|
|
@ -1049,6 +1102,7 @@ class InferenceBackend:
|
|||
template_messages = [{"role": "system", "content": system_prompt}] + messages
|
||||
else:
|
||||
template_messages = messages
|
||||
reasoning_channel_markers_resolved = False
|
||||
try:
|
||||
if not (hasattr(tokenizer, "chat_template") and tokenizer.chat_template):
|
||||
raise ValueError(
|
||||
|
|
@ -1058,6 +1112,7 @@ class InferenceBackend:
|
|||
f"Please use a model that includes a chat template, or manually set "
|
||||
f"one via tokenizer.chat_template before inference."
|
||||
)
|
||||
reasoning_channel_markers = None
|
||||
formatted_prompt = self._apply_chat_template_for_generation(
|
||||
tokenizer,
|
||||
template_messages,
|
||||
|
|
@ -1073,7 +1128,7 @@ class InferenceBackend:
|
|||
render_with_native_template_fallback,
|
||||
)
|
||||
|
||||
formatted_prompt = render_with_native_template_fallback(
|
||||
render_result = render_with_native_template_fallback(
|
||||
formatted_prompt = formatted_prompt,
|
||||
tokenizer = tokenizer,
|
||||
model_info = model_info,
|
||||
|
|
@ -1085,13 +1140,19 @@ class InferenceBackend:
|
|||
preserve_thinking = preserve_thinking,
|
||||
apply_fn = self._apply_chat_template_for_generation,
|
||||
hf_token = model_info.get("hf_token"),
|
||||
return_metadata = True,
|
||||
)
|
||||
formatted_prompt = render_result.prompt
|
||||
reasoning_channel_markers = render_result.reasoning_channel_markers
|
||||
reasoning_channel_markers_resolved = True
|
||||
|
||||
logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...")
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying chat template: {e}")
|
||||
# Fall back to manual formatting
|
||||
formatted_prompt = self.format_chat_prompt(messages, system_prompt)
|
||||
reasoning_channel_markers = None
|
||||
reasoning_channel_markers_resolved = True
|
||||
|
||||
# Step 3: generate
|
||||
yield from self.generate_stream(
|
||||
|
|
@ -1105,6 +1166,8 @@ class InferenceBackend:
|
|||
cancel_event = cancel_event,
|
||||
_adapter_state = _adapter_state,
|
||||
presence_penalty = presence_penalty,
|
||||
reasoning_channel_markers = reasoning_channel_markers,
|
||||
reasoning_channel_markers_resolved = reasoning_channel_markers_resolved,
|
||||
)
|
||||
|
||||
def _generate_vision_response(
|
||||
|
|
@ -1190,21 +1253,27 @@ class InferenceBackend:
|
|||
|
||||
# Stream with TextIteratorStreamer + background thread
|
||||
try:
|
||||
from core.inference.chat_template_helpers import detect_think_prefill
|
||||
|
||||
# Re-emit an open <think> prefill swallowed by skip_prompt (see
|
||||
# generate_stream).
|
||||
think_prefix = detect_think_prefill(
|
||||
prompt_text, getattr(raw_tokenizer, "all_special_tokens", None)
|
||||
)
|
||||
from transformers import TextIteratorStreamer
|
||||
import threading
|
||||
|
||||
streamer = TextIteratorStreamer(
|
||||
streamer = self._make_text_streamer(
|
||||
raw_tokenizer,
|
||||
protocol_source = processor,
|
||||
# The text-only VLM fallback above did not render with the
|
||||
# processor template, so its native markers do not describe
|
||||
# this request's response protocol.
|
||||
reasoning_channel_markers = detect_reasoning_channel_markers(processor)
|
||||
if image
|
||||
else None,
|
||||
reasoning_channel_markers_resolved = True,
|
||||
skip_prompt = True,
|
||||
skip_special_tokens = True,
|
||||
timeout = 0.2,
|
||||
cancel_event = cancel_event,
|
||||
use_harmony = self._is_gpt_oss_model(),
|
||||
)
|
||||
|
||||
generation_kwargs = dict(
|
||||
|
|
@ -1226,6 +1295,10 @@ class InferenceBackend:
|
|||
)
|
||||
if _pp is not None:
|
||||
generation_kwargs["logits_processor"] = _pp
|
||||
stopping_criteria = self._cancel_stopping_criteria(cancel_event)
|
||||
if stopping_criteria is not None:
|
||||
generation_kwargs["stopping_criteria"] = stopping_criteria
|
||||
active_stop_token_ids = self._generation_stop_token_ids(model, generation_kwargs)
|
||||
|
||||
err: dict[str, str] = {}
|
||||
|
||||
|
|
@ -1235,6 +1308,8 @@ class InferenceBackend:
|
|||
model.generate(**generation_kwargs)
|
||||
except Exception as e:
|
||||
err["msg"] = str(e)
|
||||
if hasattr(streamer, "abort"):
|
||||
streamer.abort()
|
||||
logger.error(f"Vision generation error in thread: {e}")
|
||||
finally:
|
||||
try:
|
||||
|
|
@ -1251,12 +1326,17 @@ class InferenceBackend:
|
|||
if think_prefix:
|
||||
yield think_prefix
|
||||
from queue import Empty
|
||||
import time
|
||||
|
||||
generation_complete = False
|
||||
cancel_deadline = None
|
||||
try:
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if cancel_deadline is None:
|
||||
cancel_deadline = time.monotonic() + 10
|
||||
elif time.monotonic() >= cancel_deadline:
|
||||
break
|
||||
try:
|
||||
new_token = next(streamer)
|
||||
except StopIteration:
|
||||
|
|
@ -1265,27 +1345,48 @@ class InferenceBackend:
|
|||
except Empty:
|
||||
if not thread.is_alive():
|
||||
generation_complete = True
|
||||
output = yield from self._drain_streamer_tail(
|
||||
streamer, output, active_stop_token_ids
|
||||
)
|
||||
break
|
||||
if cancel_deadline is not None:
|
||||
remaining = cancel_deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
thread.join(timeout = remaining)
|
||||
if thread.is_alive():
|
||||
break
|
||||
generation_complete = True
|
||||
output = yield from self._drain_streamer_tail(
|
||||
streamer, output, active_stop_token_ids
|
||||
)
|
||||
break
|
||||
continue
|
||||
if new_token:
|
||||
output += new_token
|
||||
cleaned = self._clean_generated_text(output)
|
||||
output, cleaned = self._append_stream_delta(
|
||||
output, new_token, active_stop_token_ids
|
||||
)
|
||||
yield cleaned
|
||||
finally:
|
||||
if cancel_event is not None and not generation_complete:
|
||||
cancel_event.set()
|
||||
thread.join(timeout = 10)
|
||||
join_timeout = 10
|
||||
if cancel_deadline is not None:
|
||||
join_timeout = max(0, cancel_deadline - time.monotonic())
|
||||
thread.join(timeout = join_timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning(
|
||||
"Vision generation thread did not exit after cancel/join timeout"
|
||||
)
|
||||
|
||||
if err.get("msg"):
|
||||
yield f"Error: {err['msg']}"
|
||||
raise _GenerationThreadError(err["msg"])
|
||||
|
||||
except _GenerationThreadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Vision generation error: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
raise
|
||||
|
||||
def generate_audio_input_response(
|
||||
self,
|
||||
|
|
@ -1410,11 +1511,13 @@ class InferenceBackend:
|
|||
)
|
||||
|
||||
if err.get("msg"):
|
||||
yield f"Error: {err['msg']}"
|
||||
raise _GenerationThreadError(err["msg"])
|
||||
|
||||
except _GenerationThreadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Audio input generation error: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
raise
|
||||
|
||||
def generate_whisper_response(
|
||||
self,
|
||||
|
|
@ -1447,6 +1550,86 @@ class InferenceBackend:
|
|||
from utils.datasets import is_gpt_oss_model_name
|
||||
return is_gpt_oss_model_name(model_name or self.active_model_name or "")
|
||||
|
||||
def _make_text_streamer(
|
||||
self,
|
||||
tokenizer,
|
||||
*,
|
||||
protocol_source = None,
|
||||
reasoning_channel_markers = None,
|
||||
reasoning_channel_markers_resolved: bool = False,
|
||||
skip_prompt: bool = True,
|
||||
timeout: float = 0.2,
|
||||
cancel_event = None,
|
||||
use_harmony: bool = False,
|
||||
):
|
||||
"""Create the streamer matching this model's native response protocol."""
|
||||
if use_harmony:
|
||||
try:
|
||||
return HarmonyTextStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = skip_prompt,
|
||||
timeout = timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"HarmonyTextStreamer init failed, falling back: {e}")
|
||||
return TextIteratorStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = skip_prompt,
|
||||
skip_special_tokens = True,
|
||||
timeout = timeout,
|
||||
)
|
||||
|
||||
markers = (
|
||||
reasoning_channel_markers
|
||||
if reasoning_channel_markers_resolved
|
||||
else reasoning_channel_markers
|
||||
or detect_reasoning_channel_markers(protocol_source or tokenizer)
|
||||
)
|
||||
if markers is not None:
|
||||
return ReasoningTextIteratorStreamer(
|
||||
tokenizer,
|
||||
markers = markers,
|
||||
skip_prompt = skip_prompt,
|
||||
timeout = timeout,
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
return TextIteratorStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = skip_prompt,
|
||||
skip_special_tokens = True,
|
||||
timeout = timeout,
|
||||
)
|
||||
|
||||
def _append_stream_delta(
|
||||
self,
|
||||
output: str,
|
||||
new_token: str,
|
||||
stop_token_ids = None,
|
||||
):
|
||||
"""Append a streamer delta and apply response-boundary cleanup."""
|
||||
output += new_token
|
||||
return output, self._clean_generated_text(output, stop_token_ids = stop_token_ids)
|
||||
|
||||
def _drain_streamer_tail(
|
||||
self,
|
||||
streamer,
|
||||
output: str,
|
||||
stop_token_ids = None,
|
||||
):
|
||||
"""Drain queued streamer text after the producer exits."""
|
||||
while True:
|
||||
try:
|
||||
new_token = next(streamer)
|
||||
except StopIteration:
|
||||
return output
|
||||
except Exception:
|
||||
return output
|
||||
if new_token:
|
||||
output, cleaned = self._append_stream_delta(
|
||||
output, new_token, stop_token_ids = stop_token_ids
|
||||
)
|
||||
yield cleaned
|
||||
|
||||
def generate_stream(
|
||||
self,
|
||||
prompt: str,
|
||||
|
|
@ -1459,6 +1642,8 @@ class InferenceBackend:
|
|||
cancel_event = None,
|
||||
_adapter_state = None,
|
||||
presence_penalty: float = 0.0,
|
||||
reasoning_channel_markers = None,
|
||||
reasoning_channel_markers_resolved: bool = False,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Generate a streaming text response (text models only).
|
||||
|
||||
|
|
@ -1467,8 +1652,7 @@ class InferenceBackend:
|
|||
``presence_penalty`` matches the GGUF sampling path via a logits processor (0 disables it).
|
||||
"""
|
||||
if not self.active_model_name:
|
||||
yield "Error: No active model"
|
||||
return
|
||||
raise RuntimeError("No active model")
|
||||
|
||||
model_info = self.models[self.active_model_name]
|
||||
model = model_info["model"]
|
||||
|
|
@ -1481,9 +1665,7 @@ class InferenceBackend:
|
|||
try:
|
||||
inputs = tokenizer(prompt, return_tensors = "pt").to(model.device)
|
||||
|
||||
from transformers import TextIteratorStreamer
|
||||
import threading
|
||||
from core.inference.chat_template_helpers import detect_think_prefill
|
||||
|
||||
# skip_prompt swallows an open <think> prefilled by the template;
|
||||
# re-emit it so the frontend can render the thinking block.
|
||||
|
|
@ -1494,30 +1676,16 @@ class InferenceBackend:
|
|||
else detect_think_prefill(prompt, getattr(tokenizer, "all_special_tokens", None))
|
||||
)
|
||||
|
||||
# gpt-oss models: HarmonyTextStreamer parses the multi-channel
|
||||
# harmony protocol into <think> tags
|
||||
if self._is_gpt_oss_model():
|
||||
try:
|
||||
streamer = HarmonyTextStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = True,
|
||||
timeout = 0.2,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"HarmonyTextStreamer init failed, falling back: {e}")
|
||||
streamer = TextIteratorStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = True,
|
||||
skip_special_tokens = True,
|
||||
timeout = 0.2,
|
||||
)
|
||||
else:
|
||||
streamer = TextIteratorStreamer(
|
||||
tokenizer,
|
||||
skip_prompt = True,
|
||||
skip_special_tokens = True,
|
||||
timeout = 0.2,
|
||||
)
|
||||
streamer = self._make_text_streamer(
|
||||
tokenizer,
|
||||
protocol_source = model_info.get("tokenizer"),
|
||||
reasoning_channel_markers = reasoning_channel_markers,
|
||||
reasoning_channel_markers_resolved = reasoning_channel_markers_resolved,
|
||||
skip_prompt = True,
|
||||
timeout = 0.2,
|
||||
cancel_event = cancel_event,
|
||||
use_harmony = self._is_gpt_oss_model(),
|
||||
)
|
||||
|
||||
generation_kwargs = dict(
|
||||
**inputs,
|
||||
|
|
@ -1535,27 +1703,16 @@ class InferenceBackend:
|
|||
if tokenizer.pad_token_id is None
|
||||
else tokenizer.pad_token_id,
|
||||
)
|
||||
active_stop_token_ids = self._generation_stop_token_ids(model, generation_kwargs)
|
||||
# Presence penalty (GGUF parity); prompt_len excludes prompt tokens.
|
||||
_pp = _make_presence_penalty_processor(
|
||||
presence_penalty, int(inputs["input_ids"].shape[1])
|
||||
)
|
||||
if _pp is not None:
|
||||
generation_kwargs["logits_processor"] = _pp
|
||||
if cancel_event is not None:
|
||||
from transformers.generation.stopping_criteria import (
|
||||
StoppingCriteria,
|
||||
StoppingCriteriaList,
|
||||
)
|
||||
class _CancelCriteria(StoppingCriteria):
|
||||
def __init__(self, ev):
|
||||
self.ev = ev
|
||||
|
||||
def __call__(self, input_ids, scores, **kwargs):
|
||||
return self.ev.is_set()
|
||||
|
||||
generation_kwargs["stopping_criteria"] = StoppingCriteriaList(
|
||||
[_CancelCriteria(cancel_event)]
|
||||
)
|
||||
stopping_criteria = self._cancel_stopping_criteria(cancel_event)
|
||||
if stopping_criteria is not None:
|
||||
generation_kwargs["stopping_criteria"] = stopping_criteria
|
||||
|
||||
def generate_fn():
|
||||
with self._generation_lock:
|
||||
|
|
@ -1565,6 +1722,8 @@ class InferenceBackend:
|
|||
model.generate(**generation_kwargs)
|
||||
except Exception as e:
|
||||
err["msg"] = str(e)
|
||||
if hasattr(streamer, "abort"):
|
||||
streamer.abort()
|
||||
logger.error(f"Generation error: {e}")
|
||||
finally:
|
||||
try:
|
||||
|
|
@ -1582,12 +1741,17 @@ class InferenceBackend:
|
|||
if think_prefix:
|
||||
yield think_prefix
|
||||
from queue import Empty
|
||||
import time
|
||||
|
||||
generation_complete = False
|
||||
cancel_deadline = None
|
||||
try:
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
break
|
||||
if cancel_deadline is None:
|
||||
cancel_deadline = time.monotonic() + 10
|
||||
elif time.monotonic() >= cancel_deadline:
|
||||
break
|
||||
try:
|
||||
new_token = next(streamer)
|
||||
except StopIteration:
|
||||
|
|
@ -1596,11 +1760,27 @@ class InferenceBackend:
|
|||
except Empty:
|
||||
if not thread.is_alive():
|
||||
generation_complete = True
|
||||
output = yield from self._drain_streamer_tail(
|
||||
streamer, output, active_stop_token_ids
|
||||
)
|
||||
break
|
||||
if cancel_deadline is not None:
|
||||
remaining = cancel_deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
thread.join(timeout = remaining)
|
||||
if thread.is_alive():
|
||||
break
|
||||
generation_complete = True
|
||||
output = yield from self._drain_streamer_tail(
|
||||
streamer, output, active_stop_token_ids
|
||||
)
|
||||
break
|
||||
continue
|
||||
if new_token:
|
||||
output += new_token
|
||||
cleaned = self._clean_generated_text(output)
|
||||
output, cleaned = self._append_stream_delta(
|
||||
output, new_token, active_stop_token_ids
|
||||
)
|
||||
yield cleaned
|
||||
finally:
|
||||
# Set cancel_event only on early exit (user cancel), NOT on
|
||||
|
|
@ -1609,16 +1789,21 @@ class InferenceBackend:
|
|||
# disrupt the next serialized request (e.g. compare mode).
|
||||
if cancel_event is not None and not generation_complete:
|
||||
cancel_event.set()
|
||||
thread.join(timeout = 10)
|
||||
join_timeout = 10
|
||||
if cancel_deadline is not None:
|
||||
join_timeout = max(0, cancel_deadline - time.monotonic())
|
||||
thread.join(timeout = join_timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("Generation thread did not exit after cancel/join timeout")
|
||||
|
||||
if err.get("msg"):
|
||||
yield f"Error: {err['msg']}"
|
||||
raise _GenerationThreadError(err["msg"])
|
||||
|
||||
except _GenerationThreadError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error during generation: {e}")
|
||||
yield f"Error: {str(e)}"
|
||||
raise
|
||||
|
||||
# ── Audio (TTS) Generation ────────────────────────────────────
|
||||
|
||||
|
|
@ -2107,8 +2292,42 @@ class InferenceBackend:
|
|||
return img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
return img
|
||||
|
||||
def _clean_generated_text(self, text: str) -> str:
|
||||
"""Strip leaked special tokens using the tokenizer's own token list."""
|
||||
def _generation_stop_token_ids(self, model, generation_kwargs: dict):
|
||||
"""Return the stop-token ids active for a ``generate`` call."""
|
||||
if "eos_token_id" in generation_kwargs:
|
||||
return generation_kwargs.get("eos_token_id")
|
||||
generation_config = getattr(model, "generation_config", None)
|
||||
eos_token_id = getattr(generation_config, "eos_token_id", None)
|
||||
if eos_token_id is not None:
|
||||
return eos_token_id
|
||||
config = getattr(model, "config", None)
|
||||
return getattr(config, "eos_token_id", None)
|
||||
|
||||
def _cancel_stopping_criteria(self, cancel_event):
|
||||
"""Build a Transformers stopping criteria list for user cancellation."""
|
||||
if cancel_event is None:
|
||||
return None
|
||||
from transformers.generation.stopping_criteria import (
|
||||
StoppingCriteria,
|
||||
StoppingCriteriaList,
|
||||
)
|
||||
|
||||
class _CancelCriteria(StoppingCriteria):
|
||||
def __init__(self, ev):
|
||||
self.ev = ev
|
||||
|
||||
def __call__(self, input_ids, scores, **kwargs):
|
||||
return self.ev.is_set()
|
||||
|
||||
return StoppingCriteriaList([_CancelCriteria(cancel_event)])
|
||||
|
||||
def _clean_generated_text(
|
||||
self,
|
||||
text: str,
|
||||
*,
|
||||
stop_token_ids = None,
|
||||
) -> str:
|
||||
"""Strip leaked response-boundary tokens after streaming."""
|
||||
if self._is_gpt_oss_model():
|
||||
# HarmonyTextStreamer emits clean <think>...</think>. Strip any
|
||||
# harmony protocol tokens and other gpt-oss tokens (e.g.
|
||||
|
|
@ -2118,10 +2337,28 @@ class InferenceBackend:
|
|||
return text.strip()
|
||||
|
||||
tokenizer = self.models.get(self.active_model_name, {}).get("tokenizer")
|
||||
tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
|
||||
if tokenizer:
|
||||
for token in getattr(tokenizer, "all_special_tokens", []):
|
||||
if token in text:
|
||||
text = text.replace(token, "")
|
||||
if stop_token_ids is None:
|
||||
stop_token_ids = self.models.get(self.active_model_name, {}).get(
|
||||
"chat_turn_end_eos_ids"
|
||||
)
|
||||
if isinstance(stop_token_ids, int):
|
||||
stop_token_ids = (stop_token_ids,)
|
||||
for token_id in stop_token_ids or ():
|
||||
try:
|
||||
token = tokenizer.convert_ids_to_tokens(int(token_id))
|
||||
except Exception:
|
||||
token = None
|
||||
if isinstance(token, str) and token and text.endswith(token):
|
||||
text = text[: -len(token)]
|
||||
elif (
|
||||
isinstance(token, str)
|
||||
and token
|
||||
and text.endswith("</think>")
|
||||
and text[: -len("</think>")].endswith(token)
|
||||
):
|
||||
text = text[: -len("</think>") - len(token)] + "</think>"
|
||||
return text.strip()
|
||||
|
||||
def _load_chat_template_info(self, model_name: str):
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ OpenAI-compatible /v1/chat/completions endpoint.
|
|||
|
||||
import atexit
|
||||
import contextlib
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -82,6 +83,7 @@ from core.inference.tool_call_parser import (
|
|||
)
|
||||
from core.inference.tool_loop_controller import (
|
||||
ToolLoopController,
|
||||
append_deferred_nudges,
|
||||
tool_event_provenance,
|
||||
)
|
||||
from state.tool_approvals import (
|
||||
|
|
@ -998,6 +1000,283 @@ def _cached_colocated_split_main(
|
|||
return None
|
||||
|
||||
|
||||
def _cached_variant_resolution(repo_id: str, hf_variant: str) -> tuple[Optional[str], list[str]]:
|
||||
"""Find a cached main GGUF and its shards for a variant."""
|
||||
candidate = next(_cached_variant_candidates(repo_id, hf_variant), None)
|
||||
if candidate is None:
|
||||
return None, []
|
||||
_, main, shards, _ = candidate
|
||||
return main, shards
|
||||
|
||||
|
||||
def _cached_variant_candidates(
|
||||
repo_id: str,
|
||||
hf_variant: str,
|
||||
*,
|
||||
require_mmproj: bool = False,
|
||||
) -> Generator[tuple[str, str, list[str], Path], None, None]:
|
||||
"""Yield complete cached variant copies in snapshot preference order."""
|
||||
try:
|
||||
from utils.models.model_config import _iter_hf_cache_snapshots
|
||||
for snap in _iter_hf_cache_snapshots(repo_id):
|
||||
cached_files = _gguf_snapshot_files(snap)
|
||||
matches = _gguf_files_for_variant(cached_files, hf_variant)
|
||||
if not matches:
|
||||
continue
|
||||
main = matches[0]
|
||||
shards = _gguf_extra_shards(matches, main)
|
||||
split = _SHARD_FULL_RE.match(main)
|
||||
if split:
|
||||
numbers = {
|
||||
int(match.group(2))
|
||||
for path in [main, *shards]
|
||||
if (match := _SHARD_FULL_RE.match(path))
|
||||
}
|
||||
if numbers != set(range(1, int(split.group(3)) + 1)):
|
||||
continue
|
||||
main_path = snap.joinpath(*main.replace("\\", "/").split("/"))
|
||||
if not main_path.is_file() or not _snapshot_has_all_shards(
|
||||
str(main_path), main, shards, {}
|
||||
):
|
||||
continue
|
||||
if require_mmproj and not _pick_mmproj(cached_files):
|
||||
continue
|
||||
yield str(main_path), main, shards, snap
|
||||
except Exception as e:
|
||||
logger.debug(f"Cache lookup for variant failed: {e}")
|
||||
|
||||
|
||||
def _cached_candidate_matches_revision_size(
|
||||
repo_id: str, candidate: tuple[str, str, list[str], Path], hf_token: Optional[str]
|
||||
) -> bool:
|
||||
"""Check cached byte sizes against the snapshot's own Hub revision.
|
||||
|
||||
A snapshot pointer is normally published only after its blob is complete.
|
||||
When the old revision is still queryable, also compare every weight file's
|
||||
size so a manually truncated cache entry is not treated as reusable. If
|
||||
metadata cannot be reached, retain the cache's normal offline semantics.
|
||||
"""
|
||||
main_path, main, shards, snap = candidate
|
||||
paths = [main, *shards]
|
||||
try:
|
||||
from huggingface_hub import get_paths_info
|
||||
infos = list(
|
||||
get_paths_info(
|
||||
repo_id,
|
||||
paths,
|
||||
revision = snap.name,
|
||||
token = hf_token,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Could not size-check cached GGUF %s at revision %s: %s",
|
||||
repo_id,
|
||||
snap.name,
|
||||
e,
|
||||
)
|
||||
return True
|
||||
|
||||
if not infos:
|
||||
# The Hub answers an unknown (e.g. force-pushed away) revision with an
|
||||
# empty result, not an error; treat it like unreachable metadata.
|
||||
return True
|
||||
expected_sizes = {info.path: info.size for info in infos if info.size is not None}
|
||||
if any(path not in expected_sizes for path in paths):
|
||||
return False
|
||||
try:
|
||||
if os.path.getsize(main_path) < expected_sizes[main]:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return _snapshot_has_all_shards(main_path, main, shards, expected_sizes)
|
||||
|
||||
|
||||
def _cached_complete_candidate(
|
||||
repo_id: str, gguf_filename: Optional[str], shards: list[str]
|
||||
) -> Optional[tuple[str, str, list[str], Path]]:
|
||||
"""Return one complete exact-filename cache candidate with snapshot context."""
|
||||
if not gguf_filename:
|
||||
return None
|
||||
if shards:
|
||||
main_path = _cached_colocated_split_main(repo_id, gguf_filename, shards, {})
|
||||
else:
|
||||
m = _SHARD_FULL_RE.match(gguf_filename)
|
||||
if m and int(m.group(3)) > 1:
|
||||
return None
|
||||
main_path = _cached_hf_snapshot_file(repo_id, gguf_filename)
|
||||
if main_path is None:
|
||||
return None
|
||||
snap = _snapshot_dir_of(main_path)
|
||||
if snap is None:
|
||||
return None
|
||||
return main_path, gguf_filename, shards, snap
|
||||
|
||||
|
||||
def cached_gguf_for_load(
|
||||
hf_repo: str,
|
||||
hf_variant: Optional[str],
|
||||
*,
|
||||
require_mmproj: bool = False,
|
||||
verify_sizes: bool = False,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return a cached GGUF that can be loaded without downloading."""
|
||||
if not hf_variant:
|
||||
return None
|
||||
hf_repo = _resolve_repo_id_casing(hf_repo)
|
||||
for candidate in _cached_variant_candidates(
|
||||
hf_repo,
|
||||
hf_variant,
|
||||
require_mmproj = require_mmproj,
|
||||
):
|
||||
if verify_sizes and not _cached_candidate_matches_revision_size(
|
||||
hf_repo, candidate, hf_token
|
||||
):
|
||||
continue
|
||||
return candidate[0]
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_dir_of(path: str) -> Optional[Path]:
|
||||
"""Return the HF cache snapshot containing path, if any."""
|
||||
try:
|
||||
p = Path(os.path.abspath(path))
|
||||
except OSError:
|
||||
return None
|
||||
for ancestor in p.parents:
|
||||
if ancestor.parent.name == "snapshots":
|
||||
return ancestor
|
||||
return None
|
||||
|
||||
|
||||
def _companion_snapshot_sibling(
|
||||
near_path: str, pick: Callable[[list[str]], Optional[str]]
|
||||
) -> Optional[str]:
|
||||
"""Find a companion in the same snapshot as near_path."""
|
||||
snap = _snapshot_dir_of(near_path)
|
||||
if snap is None:
|
||||
return None
|
||||
try:
|
||||
sibling = pick(_gguf_snapshot_files(snap))
|
||||
except Exception:
|
||||
return None
|
||||
if not sibling:
|
||||
return None
|
||||
candidate = snap / sibling
|
||||
return str(candidate) if candidate.is_file() else None
|
||||
|
||||
|
||||
def _pick_mmproj(candidates: list[str]) -> Optional[str]:
|
||||
mmproj_files = sorted(
|
||||
f for f in candidates if f.lower().endswith(".gguf") and "mmproj" in Path(f).name.lower()
|
||||
)
|
||||
if not mmproj_files:
|
||||
return None
|
||||
return next((f for f in mmproj_files if f.lower().endswith("-f16.gguf")), mmproj_files[0])
|
||||
|
||||
|
||||
def _hub_download_in_flight(hf_repo: str) -> bool:
|
||||
try:
|
||||
from hub.utils.download_registry import get_models_registry
|
||||
return bool(get_models_registry().active_job_refs(hf_repo))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _hub_download_blocks_gguf_load(
|
||||
hf_repo: str,
|
||||
hf_variant: Optional[str],
|
||||
*,
|
||||
require_mmproj: bool = False,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Whether an active Hub job makes this GGUF load unsafe.
|
||||
|
||||
Same-variant jobs can reclaim the stale snapshot a load would reuse, so
|
||||
they always block. Other jobs block only when this load lacks a complete
|
||||
cached copy and would write to the shared cache itself.
|
||||
"""
|
||||
try:
|
||||
from hub.utils.download_registry import get_models_registry
|
||||
|
||||
registry = get_models_registry()
|
||||
if not registry.active_job_refs(hf_repo):
|
||||
return False
|
||||
if registry.has_active_variant(hf_repo, hf_variant):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return (
|
||||
cached_gguf_for_load(
|
||||
hf_repo,
|
||||
hf_variant,
|
||||
require_mmproj = require_mmproj,
|
||||
verify_sizes = True,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
# Active GGUF loads by normalized repo ID.
|
||||
_LOADS_IN_FLIGHT: dict[str, int] = {}
|
||||
_LOADS_IN_FLIGHT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def gguf_load_in_flight(hf_repo: Optional[str]):
|
||||
"""Track an HF GGUF load until the context exits."""
|
||||
key = (hf_repo or "").strip().lower()
|
||||
if not key:
|
||||
yield
|
||||
return
|
||||
with _LOADS_IN_FLIGHT_LOCK:
|
||||
_LOADS_IN_FLIGHT[key] = _LOADS_IN_FLIGHT.get(key, 0) + 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with _LOADS_IN_FLIGHT_LOCK:
|
||||
remaining = _LOADS_IN_FLIGHT.get(key, 1) - 1
|
||||
if remaining <= 0:
|
||||
_LOADS_IN_FLIGHT.pop(key, None)
|
||||
else:
|
||||
_LOADS_IN_FLIGHT[key] = remaining
|
||||
|
||||
|
||||
def hf_gguf_load_in_flight(hf_repo: str) -> bool:
|
||||
"""Return whether a GGUF load is active for hf_repo."""
|
||||
key = (hf_repo or "").strip().lower()
|
||||
if not key:
|
||||
return False
|
||||
with _LOADS_IN_FLIGHT_LOCK:
|
||||
return _LOADS_IN_FLIGHT.get(key, 0) > 0
|
||||
|
||||
|
||||
def _with_gguf_load_marker(load: Callable):
|
||||
"""Keep an HF repo marked for the full synchronous load call."""
|
||||
|
||||
@functools.wraps(load)
|
||||
def wrapped(self, *args, **kwargs):
|
||||
hf_repo = kwargs.get("hf_repo")
|
||||
with gguf_load_in_flight(hf_repo):
|
||||
if hf_repo and _hub_download_blocks_gguf_load(
|
||||
hf_repo,
|
||||
kwargs.get("hf_variant"),
|
||||
require_mmproj = bool(
|
||||
kwargs.get("is_vision")
|
||||
and not extra_args_disable_mmproj(kwargs.get("extra_args"))
|
||||
),
|
||||
hf_token = kwargs.get("hf_token"),
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"'{hf_repo}' is currently being downloaded by the download manager"
|
||||
)
|
||||
return load(self, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _gguf_extra_shards(files: Iterable[str], first_shard: str) -> list[str]:
|
||||
m = _SHARD_FULL_RE.match(first_shard)
|
||||
if not m:
|
||||
|
|
@ -1541,6 +1820,31 @@ def _is_external_link(path: Path) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# Inkling's template takes a numeric thinking-effort dial (0..0.99) and its
|
||||
# float() coercion turns unrecognized named levels into 0, i.e. no thinking.
|
||||
# Map OpenAI-style names to the values the model was trained on. Module-level
|
||||
# so duck-typed engine stand-ins in tests do not need the attribute.
|
||||
_INKLING_REASONING_EFFORT = {
|
||||
"none": 0.0,
|
||||
"minimal": 0.1,
|
||||
"low": 0.2,
|
||||
"medium": 0.7,
|
||||
"high": 0.9,
|
||||
"xhigh": 0.99,
|
||||
"max": 0.99,
|
||||
}
|
||||
|
||||
|
||||
def _coerce_reasoning_effort(architecture, kwargs: dict) -> dict:
|
||||
if architecture == "inkling":
|
||||
effort = kwargs.get("reasoning_effort")
|
||||
if isinstance(effort, str):
|
||||
mapped = _INKLING_REASONING_EFFORT.get(effort.strip().lower())
|
||||
if mapped is not None:
|
||||
kwargs["reasoning_effort"] = mapped
|
||||
return kwargs
|
||||
|
||||
|
||||
class LlamaCppBackend:
|
||||
"""Manages a llama-server subprocess for GGUF model inference.
|
||||
|
||||
|
|
@ -1941,36 +2245,15 @@ class LlamaCppBackend:
|
|||
def reasoning_default(self) -> bool:
|
||||
return self._reasoning_default
|
||||
|
||||
# Inkling's template takes a numeric thinking-effort dial (0..0.99) and its
|
||||
# float() coercion turns unrecognized named levels into 0, i.e. no thinking.
|
||||
# Map OpenAI-style names to the values the model was trained on.
|
||||
_INKLING_REASONING_EFFORT = {
|
||||
"none": 0.0,
|
||||
"minimal": 0.2,
|
||||
"low": 0.2,
|
||||
"medium": 0.7,
|
||||
"high": 0.9,
|
||||
"xhigh": 0.99,
|
||||
"max": 0.99,
|
||||
}
|
||||
|
||||
def _coerce_reasoning_effort(self, kwargs: dict) -> dict:
|
||||
if getattr(self, "_architecture", None) == "inkling":
|
||||
effort = kwargs.get("reasoning_effort")
|
||||
if isinstance(effort, str):
|
||||
mapped = self._INKLING_REASONING_EFFORT.get(effort.strip().lower())
|
||||
if mapped is not None:
|
||||
kwargs["reasoning_effort"] = mapped
|
||||
return kwargs
|
||||
|
||||
def _reasoning_kwargs(self, enable_thinking: bool) -> dict:
|
||||
if self._reasoning_style == "enable_thinking_effort":
|
||||
# GLM-5.2-style: enable_thinking is the on/off gate; when on, leave
|
||||
# the template's default effort (max) in place.
|
||||
return {"enable_thinking": enable_thinking}
|
||||
if self._reasoning_style == "reasoning_effort":
|
||||
return self._coerce_reasoning_effort(
|
||||
{"reasoning_effort": "high" if enable_thinking else "low"}
|
||||
return _coerce_reasoning_effort(
|
||||
getattr(self, "_architecture", None),
|
||||
{"reasoning_effort": "high" if enable_thinking else "low"},
|
||||
)
|
||||
return {"enable_thinking": enable_thinking}
|
||||
|
||||
|
|
@ -2019,7 +2302,7 @@ class LlamaCppBackend:
|
|||
kwargs["enable_thinking"] = enable_thinking
|
||||
if self._supports_preserve_thinking and preserve_thinking is not None:
|
||||
kwargs["preserve_thinking"] = preserve_thinking
|
||||
self._coerce_reasoning_effort(kwargs)
|
||||
_coerce_reasoning_effort(getattr(self, "_architecture", None), kwargs)
|
||||
return kwargs or None
|
||||
|
||||
@property
|
||||
|
|
@ -3801,13 +4084,13 @@ class LlamaCppBackend:
|
|||
hf_repo: str,
|
||||
free_bytes: int,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> Optional[tuple[str, int]]:
|
||||
) -> Optional[tuple[str, int, list[str]]]:
|
||||
"""Find the smallest GGUF variant (including all shards) that fits.
|
||||
|
||||
Groups split shards by variant prefix and sums their sizes (e.g.
|
||||
UD-Q4_K_XL with 9 shards of 50 GB each = 450 GB total).
|
||||
|
||||
Returns (first_shard_filename, total_size_bytes) or None.
|
||||
Returns (first_shard_filename, total_size_bytes, extra_shards) or None.
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import get_paths_info, list_repo_files
|
||||
|
|
@ -3843,9 +4126,13 @@ class LlamaCppBackend:
|
|||
|
||||
# Smallest that fits
|
||||
variant_sizes.sort(key = lambda x: x[1])
|
||||
for first_file, total_size, _ in variant_sizes:
|
||||
for first_file, total_size, shard_files in variant_sizes:
|
||||
if total_size > 0 and total_size <= free_bytes:
|
||||
return first_file, total_size
|
||||
return (
|
||||
first_file,
|
||||
total_size,
|
||||
[path for path in sorted(shard_files) if path != first_file],
|
||||
)
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
|
|
@ -4441,42 +4728,52 @@ class LlamaCppBackend:
|
|||
except Exception as e:
|
||||
logger.warning(f"Could not list repo files: {e}")
|
||||
|
||||
# Offline: resolve variant -> filename from the local HF cache.
|
||||
# The heuristic below assumes filenames echo the repo name, which
|
||||
# breaks for e.g. Qwen3.6-27B-MTP-GGUF (no "MTP" in file). Match
|
||||
# against the rel path (not just basename) so subdir layouts like
|
||||
# ``BF16/foo.gguf`` are findable.
|
||||
# Fall back to the local cache when the repo listing is unavailable.
|
||||
if not gguf_filename:
|
||||
try:
|
||||
from utils.models.model_config import _iter_hf_cache_snapshots
|
||||
for snap in _iter_hf_cache_snapshots(hf_repo):
|
||||
cached_files = _gguf_snapshot_files(snap)
|
||||
matches = _gguf_files_for_variant(cached_files, hf_variant)
|
||||
if not matches:
|
||||
continue
|
||||
gguf_filename = matches[0]
|
||||
gguf_extra_shards = _gguf_extra_shards(matches, gguf_filename)
|
||||
logger.info(
|
||||
"Resolved variant %s -> %s from local HF cache",
|
||||
hf_variant,
|
||||
gguf_filename,
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Offline cache lookup for variant failed: {e}")
|
||||
cached_name, cached_shards = _cached_variant_resolution(hf_repo, hf_variant)
|
||||
if cached_name:
|
||||
gguf_filename = cached_name
|
||||
gguf_extra_shards = cached_shards
|
||||
logger.info(
|
||||
"Resolved variant %s -> %s from local HF cache",
|
||||
hf_variant,
|
||||
gguf_filename,
|
||||
)
|
||||
|
||||
if not gguf_filename:
|
||||
repo_name = hf_repo.split("/")[-1].replace("-GGUF", "")
|
||||
gguf_filename = f"{repo_name}-{hf_variant}.gguf"
|
||||
|
||||
# Prefer the existing model. Updates use force=True to fetch a new revision.
|
||||
if not force:
|
||||
if hf_variant:
|
||||
# Resolve by variant so a newer revision's filename does not hide
|
||||
# the complete older copy. Size-check against that older snapshot's
|
||||
# own revision when its metadata remains available.
|
||||
cached_main = cached_gguf_for_load(
|
||||
hf_repo,
|
||||
hf_variant,
|
||||
verify_sizes = True,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
else:
|
||||
candidate = _cached_complete_candidate(hf_repo, gguf_filename, gguf_extra_shards)
|
||||
cached_main = (
|
||||
candidate[0]
|
||||
if candidate is not None
|
||||
and _cached_candidate_matches_revision_size(hf_repo, candidate, hf_token)
|
||||
else None
|
||||
)
|
||||
if cached_main is not None:
|
||||
logger.info(f"Reusing cached GGUF: {cached_main}")
|
||||
return cached_main
|
||||
|
||||
# Check disk space; fall back to a smaller variant if needed
|
||||
all_gguf_files = [gguf_filename] + gguf_extra_shards
|
||||
expected_sizes: dict[str, int] = {}
|
||||
try:
|
||||
from huggingface_hub import get_paths_info, try_to_load_from_cache
|
||||
|
||||
path_infos = list(get_paths_info(hf_repo, all_gguf_files, token = hf_token))
|
||||
expected_sizes = {p.path: p.size for p in path_infos if p.size}
|
||||
total_bytes = sum((p.size or 0) for p in path_infos)
|
||||
|
||||
# Subtract bytes already in the HF cache so we only preflight
|
||||
|
|
@ -4485,25 +4782,10 @@ class LlamaCppBackend:
|
|||
# cold whenever free disk is below the full weight footprint,
|
||||
# even though nothing needs downloading.
|
||||
already_cached_bytes = 0
|
||||
# Cross-snapshot / case-variant cache reuse is offline-only (see the download
|
||||
# path below); online, hf_hub_download fetches the current revision and
|
||||
# resumes partials, so an old snapshot must not be counted as cached here or
|
||||
# the preflight would under-count the download and skip the disk fallback.
|
||||
# Count only files that can resume this download.
|
||||
offline = _hf_env_offline()
|
||||
# A split GGUF whose shards are not co-located in a single snapshot is
|
||||
# refetched as a whole set later, so it must not be counted as cached here.
|
||||
split_needs_refetch = False
|
||||
if offline and not force and gguf_extra_shards:
|
||||
# Scan all snapshots for one that holds the whole set co-located, so a
|
||||
# newer snapshot with only the first shard does not mask an older
|
||||
# complete one and needlessly trip the disk fallback.
|
||||
if (
|
||||
_cached_colocated_split_main(
|
||||
hf_repo, gguf_filename, gguf_extra_shards, expected_sizes
|
||||
)
|
||||
is None
|
||||
):
|
||||
split_needs_refetch = True
|
||||
# Offline split sets are reusable only when every shard shares a snapshot.
|
||||
split_needs_refetch = bool(offline and not force and gguf_extra_shards)
|
||||
if not force and not split_needs_refetch:
|
||||
for p in path_infos:
|
||||
if not p.size:
|
||||
|
|
@ -4564,32 +4846,26 @@ class LlamaCppBackend:
|
|||
hf_token,
|
||||
)
|
||||
if smaller:
|
||||
fallback_file, fallback_size = smaller
|
||||
fallback_file, fallback_size, fallback_shards = smaller
|
||||
logger.info(
|
||||
f"Selected variant too large ({total_gb:.1f} GB), "
|
||||
f"falling back to {fallback_file} ({fallback_size / (1024**3):.1f} GB)"
|
||||
)
|
||||
gguf_filename = fallback_file
|
||||
_m = _SHARD_RE.match(gguf_filename)
|
||||
_prefix = _m.group(1) if _m else None
|
||||
if _prefix:
|
||||
prefix_lower = _prefix.lower()
|
||||
gguf_extra_shards = sorted(
|
||||
f
|
||||
for f in all_gguf_files
|
||||
if f.lower().startswith(prefix_lower)
|
||||
and f != gguf_filename
|
||||
and not _is_companion_gguf_path(f)
|
||||
gguf_extra_shards = fallback_shards
|
||||
|
||||
# The selected fallback is a new load target. Apply the
|
||||
# same any-revision reuse policy before starting a fetch.
|
||||
fallback_candidate = _cached_complete_candidate(
|
||||
hf_repo, gguf_filename, gguf_extra_shards
|
||||
)
|
||||
if fallback_candidate is not None and (
|
||||
_cached_candidate_matches_revision_size(
|
||||
hf_repo, fallback_candidate, hf_token
|
||||
)
|
||||
else:
|
||||
gguf_extra_shards = []
|
||||
# Record the fallback's size so the later cache-reuse probe can
|
||||
# size-verify it; only for a single-file fallback, since
|
||||
# _find_smallest_fitting_variant returns the whole-variant size
|
||||
# and using that as the first shard's expected size would reject
|
||||
# a valid cached first shard of a split fallback.
|
||||
if not gguf_extra_shards:
|
||||
expected_sizes[fallback_file] = fallback_size
|
||||
):
|
||||
logger.info(f"Reusing cached fallback GGUF: {fallback_candidate[0]}")
|
||||
return fallback_candidate[0]
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Not enough disk space to download any variant. "
|
||||
|
|
@ -4609,45 +4885,25 @@ class LlamaCppBackend:
|
|||
raise RuntimeError("Cancelled")
|
||||
dl_start = time.monotonic()
|
||||
# Xet primary, HTTP fallback on stall; per-file so finished shards stay cached.
|
||||
local_path = None
|
||||
# Reuse a cached copy from another snapshot / case-variant repo dir only when
|
||||
# offline. Online, fall through to hf_hub_download so its revision/etag check
|
||||
# fetches the current file (and resumes a partial) instead of serving a stale
|
||||
# same-name blob from an older revision.
|
||||
if not force and _hf_env_offline():
|
||||
if gguf_extra_shards:
|
||||
# A split GGUF must load every shard from one snapshot; reuse only a
|
||||
# snapshot that holds the whole set co-located, scanning past a newer
|
||||
# snapshot that has just the first shard while an older one is complete.
|
||||
local_path = _cached_colocated_split_main(
|
||||
hf_repo, gguf_filename, gguf_extra_shards, expected_sizes
|
||||
)
|
||||
else:
|
||||
local_path = _cached_hf_snapshot_file(
|
||||
hf_repo,
|
||||
gguf_filename,
|
||||
expected_size = expected_sizes.get(gguf_filename),
|
||||
)
|
||||
if local_path is None:
|
||||
local_path = hf_hub_download_with_xet_fallback(
|
||||
local_path = hf_hub_download_with_xet_fallback(
|
||||
hf_repo,
|
||||
gguf_filename,
|
||||
hf_token,
|
||||
cancel_event = cancel_event,
|
||||
on_status = lambda m: logger.info(m),
|
||||
force_download = force,
|
||||
)
|
||||
for shard in gguf_extra_shards:
|
||||
if cancel_event.is_set():
|
||||
raise RuntimeError("Cancelled")
|
||||
logger.info(f"Resolving GGUF shard: {shard}")
|
||||
hf_hub_download_with_xet_fallback(
|
||||
hf_repo,
|
||||
gguf_filename,
|
||||
shard,
|
||||
hf_token,
|
||||
cancel_event = cancel_event,
|
||||
on_status = lambda m: logger.info(m),
|
||||
force_download = force,
|
||||
)
|
||||
for shard in gguf_extra_shards:
|
||||
if cancel_event.is_set():
|
||||
raise RuntimeError("Cancelled")
|
||||
logger.info(f"Resolving GGUF shard: {shard}")
|
||||
hf_hub_download_with_xet_fallback(
|
||||
hf_repo,
|
||||
shard,
|
||||
hf_token,
|
||||
cancel_event = cancel_event,
|
||||
force_download = force,
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, RuntimeError) and "Cancelled" in str(e):
|
||||
raise
|
||||
|
|
@ -4670,10 +4926,12 @@ class LlamaCppBackend:
|
|||
pick: Callable[[list[str]], Optional[str]],
|
||||
label: str,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
near_path: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve and fetch a companion GGUF (mmproj / MTP drafter) by name.
|
||||
|
||||
Tries the live repo file list, then the local HF cache snapshots
|
||||
Prefers a companion co-located with ``near_path``'s cache snapshot,
|
||||
then tries the live repo file list, then the local HF cache snapshots
|
||||
(offline, same fallback as _download_gguf), then hf_hub_download.
|
||||
Runs WITHOUT self._lock (like _download_gguf); honors _cancel_event so
|
||||
an /unload between the main download and here skips the fetch.
|
||||
|
|
@ -4683,6 +4941,17 @@ class LlamaCppBackend:
|
|||
if cancel_event.is_set():
|
||||
return None
|
||||
|
||||
# Keep companion files in the main GGUF's snapshot.
|
||||
if near_path:
|
||||
cached = _companion_snapshot_sibling(near_path, pick)
|
||||
if cached:
|
||||
logger.info("Reusing cached %s: %s", label, cached)
|
||||
return cached
|
||||
|
||||
if _hub_download_in_flight(hf_repo):
|
||||
logger.info("Skipping %s download while a hub download is active", label)
|
||||
return None
|
||||
|
||||
target: Optional[str] = None
|
||||
from huggingface_hub import list_repo_files
|
||||
|
||||
|
|
@ -4755,33 +5024,23 @@ class LlamaCppBackend:
|
|||
hf_repo: str,
|
||||
hf_token: Optional[str] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
near_path: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Download the mmproj (vision projection) file from a GGUF repo.
|
||||
|
||||
Prefers mmproj-F16.gguf, else any mmproj*.gguf. Returns the local
|
||||
path, or None if none exists. ``cancel_event`` overrides
|
||||
``self._cancel_event`` (defaults to it).
|
||||
``self._cancel_event`` (defaults to it). ``near_path`` prefers a
|
||||
copy co-located with the main GGUF's cache snapshot.
|
||||
"""
|
||||
|
||||
def _pick_mmproj(candidates: list[str]) -> Optional[str]:
|
||||
mmproj_files = sorted(
|
||||
f
|
||||
for f in candidates
|
||||
if f.lower().endswith(".gguf") and "mmproj" in Path(f).name.lower()
|
||||
)
|
||||
if not mmproj_files:
|
||||
return None
|
||||
for f in mmproj_files:
|
||||
if f.lower().endswith("-f16.gguf"):
|
||||
return f
|
||||
return mmproj_files[0]
|
||||
|
||||
return self._download_companion_gguf(
|
||||
hf_repo = hf_repo,
|
||||
hf_token = hf_token,
|
||||
pick = _pick_mmproj,
|
||||
label = "mmproj",
|
||||
cancel_event = cancel_event,
|
||||
near_path = near_path,
|
||||
)
|
||||
|
||||
def _cached_repo_mtp_drafter(self, hf_repo: str) -> Optional[str]:
|
||||
|
|
@ -4812,6 +5071,7 @@ class LlamaCppBackend:
|
|||
*,
|
||||
hf_repo: str,
|
||||
hf_token: Optional[str] = None,
|
||||
near_path: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Download the separate MTP drafter (speculative head) from a GGUF repo.
|
||||
|
||||
|
|
@ -4823,16 +5083,6 @@ class LlamaCppBackend:
|
|||
are intentionally skipped. Returns the local path, or None.
|
||||
"""
|
||||
|
||||
# Offline, reuse any drafter already on disk (a fresh copy can't be
|
||||
# fetched). Online, _download_companion_gguf/hf_hub_download reuse the
|
||||
# current cached file and refetch a changed one, so skip the probe here
|
||||
# rather than pair new weights with a stale draft.
|
||||
if _hf_env_offline():
|
||||
cached = self._cached_repo_mtp_drafter(hf_repo)
|
||||
if cached:
|
||||
logger.info(f"Reusing cached MTP drafter (offline): {cached}")
|
||||
return cached
|
||||
|
||||
def _pick_mtp(candidates: list[str]) -> Optional[str]:
|
||||
# Root-level only: MTP/ subdir copies now share the mtp- prefix but
|
||||
# are explicit-selection, not auto-fetch (they'd sort ahead of root).
|
||||
|
|
@ -4845,11 +5095,28 @@ class LlamaCppBackend:
|
|||
)
|
||||
return mtp_files[0] if mtp_files else None
|
||||
|
||||
if near_path:
|
||||
cached = _companion_snapshot_sibling(near_path, _pick_mtp)
|
||||
if cached:
|
||||
logger.info("Reusing cached MTP drafter: %s", cached)
|
||||
return cached
|
||||
|
||||
# Offline, reuse any drafter already on disk (a fresh copy can't be
|
||||
# fetched). Online, _download_companion_gguf/hf_hub_download reuse the
|
||||
# current cached file and refetch a changed one, so skip the probe here
|
||||
# rather than pair new weights with a stale draft.
|
||||
if _hf_env_offline():
|
||||
cached = self._cached_repo_mtp_drafter(hf_repo)
|
||||
if cached:
|
||||
logger.info(f"Reusing cached MTP drafter (offline): {cached}")
|
||||
return cached
|
||||
|
||||
return self._download_companion_gguf(
|
||||
hf_repo = hf_repo,
|
||||
hf_token = hf_token,
|
||||
pick = _pick_mtp,
|
||||
label = "MTP drafter",
|
||||
near_path = near_path,
|
||||
)
|
||||
|
||||
def _resolve_launch_mmproj_path(
|
||||
|
|
@ -5431,6 +5698,7 @@ class LlamaCppBackend:
|
|||
)
|
||||
self._stdout_thread.start()
|
||||
|
||||
@_with_gguf_load_marker
|
||||
def load_model(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -5576,6 +5844,7 @@ class LlamaCppBackend:
|
|||
mmproj_path = self._download_mmproj(
|
||||
hf_repo = hf_repo,
|
||||
hf_token = hf_token,
|
||||
near_path = model_path,
|
||||
)
|
||||
# Auto-download the separate MTP drafter (e.g. Gemma) when
|
||||
# the requested spec mode can use it. Repos with the head
|
||||
|
|
@ -5593,6 +5862,7 @@ class LlamaCppBackend:
|
|||
mtp_draft_path = self._download_mtp(
|
||||
hf_repo = hf_repo,
|
||||
hf_token = hf_token,
|
||||
near_path = model_path,
|
||||
)
|
||||
elif gguf_path:
|
||||
if not Path(gguf_path).is_file():
|
||||
|
|
@ -8227,6 +8497,11 @@ class LlamaCppBackend:
|
|||
if not is_ours:
|
||||
continue
|
||||
|
||||
# A live parent means a running Studio (or the user's
|
||||
# shell) still owns it -- not an orphan.
|
||||
if LlamaCppBackend._pid_parent_is_alive(proc.info["pid"]):
|
||||
continue
|
||||
|
||||
proc.kill()
|
||||
killed += 1
|
||||
logger.info(
|
||||
|
|
@ -8279,6 +8554,9 @@ class LlamaCppBackend:
|
|||
if not owned:
|
||||
continue
|
||||
|
||||
if LlamaCppBackend._pid_parent_is_alive(pid):
|
||||
continue
|
||||
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
killed += 1
|
||||
|
|
@ -10107,6 +10385,9 @@ class LlamaCppBackend:
|
|||
|
||||
assistant_msg: dict = {"role": "assistant", "content": content_text}
|
||||
assistant_appended = False
|
||||
# Collect no-op nudges and flush them after the batch, so a no-op
|
||||
# doesn't abort it and drop the parallel calls that follow.
|
||||
deferred_noop_msgs: list = []
|
||||
|
||||
# The text-path provisional card uses the parser's default id ("call_0");
|
||||
# a Mistral-style call carries its own id and would open a duplicate. Reuse
|
||||
|
|
@ -10149,14 +10430,14 @@ class LlamaCppBackend:
|
|||
"provenance": decision.provenance,
|
||||
}
|
||||
completion = tool_controller.record_noop(decision)
|
||||
conversation.append(completion.model_message())
|
||||
deferred_noop_msgs.append(completion.model_message())
|
||||
if _forced_tool_call_pending:
|
||||
_forced_tool_call_pending = False
|
||||
logger.info(
|
||||
"Suppressed local GGUF tool call as internal no-op: "
|
||||
f"action={decision.action} tool={decision.tool_name}"
|
||||
)
|
||||
break
|
||||
continue
|
||||
|
||||
if not assistant_appended:
|
||||
assistant_msg["tool_calls"] = [decision.as_assistant_tool_call()]
|
||||
|
|
@ -10275,6 +10556,8 @@ class LlamaCppBackend:
|
|||
if _forced_tool_call_pending:
|
||||
_forced_tool_call_pending = False
|
||||
|
||||
append_deferred_nudges(conversation, deferred_noop_msgs)
|
||||
|
||||
# Close provisional cards not resolved by execution/no-op handling.
|
||||
for _pid, _pname in provisional_started_tool_calls.items():
|
||||
if _pid not in resolved_provisional_tool_call_ids:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import threading
|
|||
from typing import Optional, Generator
|
||||
from core.inference.message_content import content_to_text
|
||||
from core.inference.runtime_context import runtime_context_length
|
||||
from core.inference.chat_template_helpers import (
|
||||
ReasoningChannelNormalizer,
|
||||
normalize_reasoning_snapshots,
|
||||
)
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -533,7 +537,7 @@ class MLXInferenceBackend:
|
|||
break
|
||||
|
||||
if self._is_vlm:
|
||||
yield from self._generate_vlm(
|
||||
stream = self._generate_vlm(
|
||||
full_messages,
|
||||
image,
|
||||
temperature,
|
||||
|
|
@ -550,7 +554,7 @@ class MLXInferenceBackend:
|
|||
presence_penalty = presence_penalty,
|
||||
)
|
||||
else:
|
||||
yield from self._generate_text(
|
||||
stream = self._generate_text(
|
||||
full_messages,
|
||||
temperature,
|
||||
top_p,
|
||||
|
|
@ -565,6 +569,7 @@ class MLXInferenceBackend:
|
|||
preserve_thinking = preserve_thinking,
|
||||
presence_penalty = presence_penalty,
|
||||
)
|
||||
yield from stream
|
||||
|
||||
def _generate_text(
|
||||
self,
|
||||
|
|
@ -609,7 +614,7 @@ class MLXInferenceBackend:
|
|||
# probe and native render share a renderer. (VLM renders via the
|
||||
# processor for image tokens and is not wired here.)
|
||||
model_info = self.models.get(self.active_model_name, {})
|
||||
prompt = render_with_native_template_fallback(
|
||||
render_result = render_with_native_template_fallback(
|
||||
formatted_prompt = prompt,
|
||||
tokenizer = self._tokenizer,
|
||||
model_info = model_info,
|
||||
|
|
@ -620,7 +625,10 @@ class MLXInferenceBackend:
|
|||
reasoning_effort = reasoning_effort,
|
||||
preserve_thinking = preserve_thinking,
|
||||
hf_token = model_info.get("hf_token"),
|
||||
return_metadata = True,
|
||||
)
|
||||
prompt = render_result.prompt
|
||||
reasoning_channel_markers = render_result.reasoning_channel_markers
|
||||
|
||||
# An open <think> prefilled by the template lives in the prompt, not
|
||||
# the generated tokens; re-emit it so the frontend renders the block.
|
||||
|
|
@ -654,7 +662,17 @@ class MLXInferenceBackend:
|
|||
if not logits_processors:
|
||||
logits_processors = None
|
||||
|
||||
preserve_native_channels = reasoning_channel_markers is not None
|
||||
token_ids = []
|
||||
normalizer = (
|
||||
ReasoningChannelNormalizer(*reasoning_channel_markers)
|
||||
if reasoning_channel_markers is not None
|
||||
else None
|
||||
)
|
||||
# MLX consumers diff cumulative snapshots. Keep a prompt-prefilled
|
||||
# <think> prefix on every native-protocol snapshot just as the normal
|
||||
# decoding path does below.
|
||||
normalized_output = think_prefix
|
||||
logger.info(
|
||||
"Generating: prompt_len=%d, max_tokens=%d, model=%s, tokenizer=%s",
|
||||
len(prompt),
|
||||
|
|
@ -678,12 +696,19 @@ class MLXInferenceBackend:
|
|||
**gen_kwargs,
|
||||
):
|
||||
final_response = response
|
||||
token_ids.append(response.token)
|
||||
cumulative = self._tokenizer.decode(
|
||||
token_ids,
|
||||
skip_special_tokens = True,
|
||||
)
|
||||
yield think_prefix + cumulative
|
||||
if preserve_native_channels:
|
||||
piece = getattr(response, "text", None) or ""
|
||||
delta = normalizer.feed(piece)
|
||||
if delta:
|
||||
normalized_output += delta
|
||||
yield normalized_output
|
||||
else:
|
||||
token_ids.append(response.token)
|
||||
cumulative = self._tokenizer.decode(
|
||||
token_ids,
|
||||
skip_special_tokens = True,
|
||||
)
|
||||
yield think_prefix + cumulative
|
||||
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
|
|
@ -700,6 +725,12 @@ class MLXInferenceBackend:
|
|||
getattr(final_response, "generation_tokens", 0),
|
||||
getattr(final_response, "generation_tps", 0.0),
|
||||
)
|
||||
if normalizer is not None:
|
||||
cancelled = cancel_event is not None and cancel_event.is_set()
|
||||
tail = normalizer.drain() if cancelled else normalizer.finish()
|
||||
if tail:
|
||||
normalized_output += tail
|
||||
yield normalized_output
|
||||
|
||||
def _generate_vlm(
|
||||
self,
|
||||
|
|
@ -858,31 +889,37 @@ class MLXInferenceBackend:
|
|||
elif _rep_active:
|
||||
vlm_kwargs["repetition_penalty"] = float(repetition_penalty)
|
||||
|
||||
with self._generation_lock:
|
||||
final_response = None
|
||||
try:
|
||||
for response in vlm_stream(
|
||||
self._model,
|
||||
self._processor,
|
||||
prompt,
|
||||
images,
|
||||
**vlm_kwargs,
|
||||
):
|
||||
final_response = response
|
||||
token_text = response.text if hasattr(response, "text") else str(response)
|
||||
cumulative += token_text
|
||||
yield cumulative
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
finally:
|
||||
# mlx_vlm exposes the same stats fields as mlx_lm.
|
||||
if final_response is not None:
|
||||
self.last_generation_stats = _build_generation_stats(
|
||||
getattr(final_response, "prompt_tokens", 0),
|
||||
getattr(final_response, "prompt_tps", 0.0),
|
||||
getattr(final_response, "generation_tokens", 0),
|
||||
getattr(final_response, "generation_tps", 0.0),
|
||||
)
|
||||
def _stream_vlm_snapshots():
|
||||
nonlocal cumulative
|
||||
with self._generation_lock:
|
||||
final_response = None
|
||||
try:
|
||||
for response in vlm_stream(
|
||||
self._model,
|
||||
self._processor,
|
||||
prompt,
|
||||
images,
|
||||
**vlm_kwargs,
|
||||
):
|
||||
final_response = response
|
||||
token_text = response.text if hasattr(response, "text") else str(response)
|
||||
cumulative += token_text
|
||||
yield cumulative
|
||||
if cancel_event and cancel_event.is_set():
|
||||
break
|
||||
finally:
|
||||
# mlx_vlm exposes the same stats fields as mlx_lm.
|
||||
if final_response is not None:
|
||||
self.last_generation_stats = _build_generation_stats(
|
||||
getattr(final_response, "prompt_tokens", 0),
|
||||
getattr(final_response, "prompt_tps", 0.0),
|
||||
getattr(final_response, "generation_tokens", 0),
|
||||
getattr(final_response, "generation_tps", 0.0),
|
||||
)
|
||||
|
||||
yield from normalize_reasoning_snapshots(
|
||||
_stream_vlm_snapshots(), chat_target, cancel_event, tools = tools
|
||||
)
|
||||
|
||||
def generate_with_adapter_control(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -59,7 +59,32 @@ class GenStreamError(str):
|
|||
"Error:" by checking isinstance(chunk, GenStreamError).
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
__slots__ = ("public",)
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
value,
|
||||
*,
|
||||
public: bool = False,
|
||||
):
|
||||
obj = str.__new__(cls, value)
|
||||
obj.public = bool(public)
|
||||
return obj
|
||||
|
||||
|
||||
class GenStreamErrorRaised(RuntimeError):
|
||||
"""Internal exception form of ``GenStreamError`` for generator boundaries."""
|
||||
|
||||
__slots__ = ("public",)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value,
|
||||
*,
|
||||
public: bool = False,
|
||||
):
|
||||
super().__init__(value)
|
||||
self.public = bool(public)
|
||||
|
||||
|
||||
class InferenceOrchestrator:
|
||||
|
|
@ -531,13 +556,19 @@ class InferenceOrchestrator:
|
|||
initial_resp_queue = self._resp_queue
|
||||
while True:
|
||||
if self._proc is not initial_proc or self._resp_queue is not initial_resp_queue:
|
||||
yield GenStreamError(f"Error: {self._subprocess_crash_message(crash_context)}")
|
||||
yield GenStreamError(
|
||||
f"Error: {self._subprocess_crash_message(crash_context)}",
|
||||
public = True,
|
||||
)
|
||||
return
|
||||
resp = read_one(read_timeout)
|
||||
if resp is None:
|
||||
# Check subprocess health
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield GenStreamError(f"Error: {self._subprocess_crash_message(crash_context)}")
|
||||
yield GenStreamError(
|
||||
f"Error: {self._subprocess_crash_message(crash_context)}",
|
||||
public = True,
|
||||
)
|
||||
return
|
||||
continue
|
||||
|
||||
|
|
@ -689,11 +720,11 @@ class InferenceOrchestrator:
|
|||
GPU work stays serialized; this only avoids orchestrator lock contention.
|
||||
"""
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield GenStreamError("Error: Inference subprocess is not running")
|
||||
yield GenStreamError("Error: Inference subprocess is not running", public = True)
|
||||
return
|
||||
|
||||
if not self.active_model_name:
|
||||
yield GenStreamError("Error: No active model")
|
||||
yield GenStreamError("Error: No active model", public = True)
|
||||
return
|
||||
# Latch the target model so the recheck below can detect a switch that completed
|
||||
# between _start_dispatcher and mailbox registration (mirrors the locked path's
|
||||
|
|
@ -704,7 +735,7 @@ class InferenceOrchestrator:
|
|||
# so without this early-out a compare request would enqueue a generate on the
|
||||
# outgoing model and delay the switch.
|
||||
if self._unload_pending:
|
||||
yield GenStreamError("Error: model is being unloaded")
|
||||
yield GenStreamError("Error: model is being unloaded", public = True)
|
||||
return
|
||||
|
||||
# Ensure the dispatcher runs. _start_dispatcher serializes concurrent starters under
|
||||
|
|
@ -776,7 +807,7 @@ class InferenceOrchestrator:
|
|||
# _stop_dispatcher joins the dispatcher, which itself takes that lock.
|
||||
if orphaned_dispatcher:
|
||||
self._stop_dispatcher()
|
||||
yield GenStreamError("Error: model is being unloaded")
|
||||
yield GenStreamError("Error: model is being unloaded", public = True)
|
||||
return
|
||||
|
||||
try:
|
||||
|
|
@ -1376,6 +1407,7 @@ class InferenceOrchestrator:
|
|||
use_adapter: Optional[Union[bool, str]] = None,
|
||||
stats_holder: Optional[dict] = None,
|
||||
presence_penalty: float = 0.0,
|
||||
reasoning_prefilled: bool = False,
|
||||
**_unused,
|
||||
):
|
||||
"""Run the safetensors agentic tool loop in the parent process,
|
||||
|
|
@ -1414,12 +1446,27 @@ class InferenceOrchestrator:
|
|||
presence_penalty = presence_penalty,
|
||||
)
|
||||
if use_adapter is not None:
|
||||
yield from self.generate_with_adapter_control(
|
||||
stream = self.generate_with_adapter_control(
|
||||
use_adapter = use_adapter,
|
||||
**common_kwargs,
|
||||
)
|
||||
else:
|
||||
yield from self.generate_chat_response(**common_kwargs)
|
||||
stream = self.generate_chat_response(**common_kwargs)
|
||||
close_stream = False
|
||||
try:
|
||||
for chunk in stream:
|
||||
if isinstance(chunk, GenStreamError):
|
||||
close_stream = True
|
||||
raise GenStreamErrorRaised(str(chunk), public = chunk.public)
|
||||
yield chunk
|
||||
finally:
|
||||
if close_stream:
|
||||
close = getattr(stream, "close", None)
|
||||
if callable(close):
|
||||
try:
|
||||
close()
|
||||
except Exception:
|
||||
logger.debug("failed to close errored generation stream", exc_info = True)
|
||||
|
||||
initial = list(messages)
|
||||
if system_prompt:
|
||||
|
|
@ -1441,6 +1488,7 @@ class InferenceOrchestrator:
|
|||
confirm_tool_calls = confirm_tool_calls,
|
||||
bypass_permissions = bypass_permissions,
|
||||
permission_mode = permission_mode,
|
||||
reasoning_prefilled = reasoning_prefilled,
|
||||
)
|
||||
|
||||
def generate_with_adapter_control(
|
||||
|
|
@ -1489,11 +1537,11 @@ class InferenceOrchestrator:
|
|||
readers don't consume each other's tokens off the shared resp_queue.
|
||||
"""
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield GenStreamError("Error: Inference subprocess is not running")
|
||||
yield GenStreamError("Error: Inference subprocess is not running", public = True)
|
||||
return
|
||||
|
||||
if not self.active_model_name:
|
||||
yield GenStreamError("Error: No active model")
|
||||
yield GenStreamError("Error: No active model", public = True)
|
||||
return
|
||||
expected_model = self.active_model_name
|
||||
|
||||
|
|
@ -1510,7 +1558,7 @@ class InferenceOrchestrator:
|
|||
# so we never generate on the wrong one.
|
||||
if self._unload_pending or self.active_model_name != expected_model:
|
||||
# Won the lock handoff during a switch; don't start on the outgoing model.
|
||||
yield GenStreamError("Error: model is being unloaded")
|
||||
yield GenStreamError("Error: model is being unloaded", public = True)
|
||||
return
|
||||
request_id = str(uuid.uuid4())
|
||||
image_b64 = self._pil_to_base64(image) if image is not None else None
|
||||
|
|
@ -1695,10 +1743,10 @@ class InferenceOrchestrator:
|
|||
) -> Generator[str, None, None]:
|
||||
"""Shared inner logic for audio input generation (Whisper + ASR)."""
|
||||
if not self._ensure_subprocess_alive():
|
||||
yield GenStreamError("Error: Inference subprocess is not running")
|
||||
yield GenStreamError("Error: Inference subprocess is not running", public = True)
|
||||
return
|
||||
if not self.active_model_name:
|
||||
yield GenStreamError("Error: No active model")
|
||||
yield GenStreamError("Error: No active model", public = True)
|
||||
return
|
||||
expected_model = self.active_model_name
|
||||
|
||||
|
|
@ -1707,7 +1755,7 @@ class InferenceOrchestrator:
|
|||
# cleared or swapped the model while we waited.
|
||||
if self._unload_pending or self.active_model_name != expected_model:
|
||||
# Won the lock handoff during a switch; don't start on the outgoing model.
|
||||
yield GenStreamError("Error: model is being unloaded")
|
||||
yield GenStreamError("Error: model is being unloaded", public = True)
|
||||
return
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from core.inference.tool_call_parser import (
|
|||
# pattern lists, so the safetensors streaming strip stays aligned with the parser.
|
||||
from core.tool_healing import (
|
||||
_REHEARSAL_TAIL_STRIP_RE,
|
||||
_THINK_CLOSE_RE,
|
||||
_strip_bracket_tag_calls,
|
||||
_think_spans_outside_tool_markup,
|
||||
apply_tool_strip_patterns,
|
||||
|
|
@ -57,6 +58,7 @@ from core.tool_healing import (
|
|||
)
|
||||
from core.inference.tool_loop_controller import (
|
||||
ToolLoopController,
|
||||
append_deferred_nudges,
|
||||
coerce_tool_arguments,
|
||||
status_for_tool,
|
||||
tool_event_provenance,
|
||||
|
|
@ -303,6 +305,45 @@ def _status_for_tool(tool_name: str, arguments: dict) -> str:
|
|||
return status_for_tool(tool_name, arguments)
|
||||
|
||||
|
||||
def _reprompt_intent_text(text: str, *, reasoning_prefilled: bool = False) -> str:
|
||||
"""Return visible answer text for the plan-without-action classifier.
|
||||
|
||||
Safetensors reasoning shares the cumulative text channel with the answer.
|
||||
Forward-looking phrases inside ``<think>`` / ``[THINK]`` are private
|
||||
planning, not a user-visible promise to call a tool. Match GGUF's behavior:
|
||||
classify visible content when present and fall back to reasoning only for a
|
||||
reasoning-only stall.
|
||||
"""
|
||||
prefilled_reasoning = ""
|
||||
if reasoning_prefilled:
|
||||
close = _THINK_CLOSE_RE.search(text)
|
||||
if close is None:
|
||||
return text.strip()
|
||||
prefilled_reasoning = text[: close.end()].strip()
|
||||
text = text[close.end() :].strip()
|
||||
if not text:
|
||||
return prefilled_reasoning
|
||||
|
||||
spans = _think_spans_outside_tool_markup(text)
|
||||
if not spans:
|
||||
return text.strip()
|
||||
|
||||
visible: list[str] = []
|
||||
reasoning: list[str] = []
|
||||
cursor = 0
|
||||
for start, end in spans:
|
||||
visible.append(text[cursor:start])
|
||||
reasoning.append(text[start:end])
|
||||
cursor = end
|
||||
visible.append(text[cursor:])
|
||||
|
||||
visible_text = "".join(visible).strip()
|
||||
reasoning_text = "".join(reasoning).strip()
|
||||
if visible_text:
|
||||
return visible_text
|
||||
return "\n".join(part for part in (prefilled_reasoning, reasoning_text) if part).strip()
|
||||
|
||||
|
||||
def _looks_like_enabled_bare_json(text: str, enabled_tool_names: Optional[set]) -> bool:
|
||||
"""True when ``text`` opens with an ENABLED markerless bare-JSON call; an ordinary JSON answer returns False."""
|
||||
probe = strip_llama3_leading_sentinels(text.lstrip())
|
||||
|
|
@ -447,6 +488,7 @@ def run_safetensors_tool_loop(
|
|||
confirm_tool_calls: bool = False,
|
||||
bypass_permissions: bool = False,
|
||||
permission_mode: Optional[str] = None,
|
||||
reasoning_prefilled: bool = False,
|
||||
) -> Generator[dict, None, None]:
|
||||
"""Drive an agentic tool loop on top of a cumulative-text generator.
|
||||
|
||||
|
|
@ -955,7 +997,10 @@ def run_safetensors_tool_loop(
|
|||
# (GGUF loop parity). The retry is gated on nudge_tool_calls so
|
||||
# Studio callers (which send True) always nudge, while API callers
|
||||
# who omit the flag keep today's no-reprompt behavior (opt-in).
|
||||
stripped_answer = content_accum.strip()
|
||||
intent_text = _reprompt_intent_text(
|
||||
content_accum,
|
||||
reasoning_prefilled = reasoning_prefilled,
|
||||
)
|
||||
if (
|
||||
auto_heal_tool_calls
|
||||
and nudge_tool_calls
|
||||
|
|
@ -964,7 +1009,7 @@ def run_safetensors_tool_loop(
|
|||
and not rag_autoinjected
|
||||
and not tool_denied
|
||||
and not any(record.executed for record in tool_controller.history)
|
||||
and is_short_intent_without_action(stripped_answer)
|
||||
and is_short_intent_without_action(intent_text)
|
||||
):
|
||||
reprompt_count += 1
|
||||
logger.info(
|
||||
|
|
@ -972,9 +1017,9 @@ def run_safetensors_tool_loop(
|
|||
"calling tools (%d chars)",
|
||||
reprompt_count,
|
||||
MAX_ACT_REPROMPTS,
|
||||
len(stripped_answer),
|
||||
len(intent_text),
|
||||
)
|
||||
conversation.append({"role": "assistant", "content": stripped_answer})
|
||||
conversation.append({"role": "assistant", "content": intent_text})
|
||||
tool_hint = " or ".join(_active_tool_names(active_tools)) or "an available tool"
|
||||
conversation.append(
|
||||
{
|
||||
|
|
@ -1099,6 +1144,9 @@ def run_safetensors_tool_loop(
|
|||
|
||||
assistant_msg: dict = {"role": "assistant", "content": content_text}
|
||||
assistant_appended = False
|
||||
# Collect no-op nudges and flush them after the batch, so a no-op doesn't
|
||||
# abort it and drop the parallel calls that follow.
|
||||
deferred_noop_msgs: list = []
|
||||
|
||||
for tc in tool_calls or []:
|
||||
func = tc.get("function", {}) or {}
|
||||
|
|
@ -1127,12 +1175,12 @@ def run_safetensors_tool_loop(
|
|||
"provenance": decision.provenance,
|
||||
}
|
||||
completion = tool_controller.record_noop(decision)
|
||||
conversation.append(completion.model_message())
|
||||
deferred_noop_msgs.append(completion.model_message())
|
||||
logger.info(
|
||||
"Suppressed local safetensors tool call as internal no-op: "
|
||||
f"action={decision.action} tool={decision.tool_name}"
|
||||
)
|
||||
break
|
||||
continue
|
||||
|
||||
if not assistant_appended:
|
||||
assistant_msg["tool_calls"] = [decision.as_assistant_tool_call()]
|
||||
|
|
@ -1243,6 +1291,8 @@ def run_safetensors_tool_loop(
|
|||
yield completion.tool_end_event()
|
||||
conversation.append(completion.tool_message())
|
||||
|
||||
append_deferred_nudges(conversation, deferred_noop_msgs)
|
||||
|
||||
# Clear the status badge before the next turn.
|
||||
yield {"type": "status", "text": ""}
|
||||
|
||||
|
|
|
|||
|
|
@ -266,6 +266,17 @@ def strip_result_for_model(result: str) -> str:
|
|||
return result
|
||||
|
||||
|
||||
def append_deferred_nudges(conversation: list, msgs: Sequence[dict]) -> None:
|
||||
"""Append a batch's no-op nudges as one deduped ``role=user`` message.
|
||||
|
||||
Deferred to after the batch's tool results so a no-op never splits an
|
||||
assistant's ``tool_calls`` from their ``role=tool`` results.
|
||||
"""
|
||||
contents = list(dict.fromkeys(msg["content"] for msg in msgs))
|
||||
if contents:
|
||||
conversation.append({"role": "user", "content": "\n\n".join(contents)})
|
||||
|
||||
|
||||
def _tool_name_from_schema(tool: Mapping[str, Any]) -> str:
|
||||
function = tool.get("function")
|
||||
if not isinstance(function, Mapping):
|
||||
|
|
@ -277,8 +288,9 @@ def _tool_name_from_schema(tool: Mapping[str, Any]) -> str:
|
|||
def _noop_result(reason: NoopReason, tool_name: str) -> str:
|
||||
if reason == "duplicate":
|
||||
return (
|
||||
"The previous tool request was not executed because this exact "
|
||||
"tool call already completed successfully. Do not repeat the same "
|
||||
f"One earlier request to call tool '{tool_name}' in this batch was "
|
||||
"not executed because an identical call had already completed "
|
||||
"successfully. Do not repeat the same "
|
||||
"tool call. Continue with a different enabled tool if that would "
|
||||
"materially help, or provide the final answer if you have enough "
|
||||
"information."
|
||||
|
|
@ -291,8 +303,8 @@ def _noop_result(reason: NoopReason, tool_name: str) -> str:
|
|||
"the requested final note or answer."
|
||||
)
|
||||
return (
|
||||
f"The previous tool request was not executed because tool "
|
||||
f"'{tool_name}' is not enabled for this request. Provide the "
|
||||
f"One earlier request to call tool '{tool_name}' in this batch was "
|
||||
"not executed because that tool is not enabled for this request. Provide the "
|
||||
"final answer now without calling more tools."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3600,14 +3600,18 @@ _MAX_PAGE_CHARS = 16000 # cap fetched page text (after HTML-to-MD conversion)
|
|||
# Raw download cap > _MAX_PAGE_CHARS since SSR pages embed large <head> sections
|
||||
# stripped during conversion; 512 KB still reaches article content.
|
||||
_MAX_FETCH_BYTES = 512 * 1024
|
||||
# PDF cross-reference data lives at EOF, so extraction needs the whole body.
|
||||
_MAX_PDF_FETCH_BYTES = 10 * 1024 * 1024
|
||||
_MAX_WEB_PDF_PAGES = 50
|
||||
# Control/undecodable chars, excluding text whitespace and ESC (for ANSI logs).
|
||||
# Binary when they exceed 12.5%, after allowing 16 minor encoding glitches.
|
||||
_BINARY_CHAR_RE = re.compile("[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1a\\x1c-\\x1f\\x7f-\\x9f\\ufffd]")
|
||||
_MIN_BINARY_CHARS = 16
|
||||
_BINARY_CHAR_DIVISOR = 8
|
||||
# Common binary signatures that can otherwise look text-heavy when mislabeled.
|
||||
_PDF_MAGIC = b"%PDF-"
|
||||
_BINARY_MAGIC = (
|
||||
b"%PDF-", # PDF
|
||||
_PDF_MAGIC,
|
||||
b"PK\x03\x04", # zip / docx / xlsx / pptx / epub / jar
|
||||
b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", # OLE / legacy Office
|
||||
b"\x89PNG\r\n\x1a\n", # PNG
|
||||
|
|
@ -3641,14 +3645,22 @@ def _looks_binary(text: str) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _has_binary_magic(data: bytes) -> bool:
|
||||
"""Whether a common binary signature follows optional BOM or whitespace."""
|
||||
def _magic_head(data: bytes) -> bytes:
|
||||
head = data[:1024].lstrip()
|
||||
for bom, _codec in _UNICODE_BOM_CODECS:
|
||||
if head.startswith(bom):
|
||||
head = head.removeprefix(bom).lstrip()
|
||||
break
|
||||
return head.startswith(_BINARY_MAGIC)
|
||||
return head
|
||||
|
||||
|
||||
def _has_pdf_magic(data: bytes) -> bool:
|
||||
return _magic_head(data).startswith(_PDF_MAGIC)
|
||||
|
||||
|
||||
def _has_binary_magic(data: bytes) -> bool:
|
||||
"""Whether a common binary signature follows optional BOM or whitespace."""
|
||||
return _magic_head(data).startswith(_BINARY_MAGIC)
|
||||
|
||||
|
||||
def _has_single_byte_text_evidence(data: bytes) -> bool:
|
||||
|
|
@ -3659,6 +3671,45 @@ def _has_single_byte_text_evidence(data: bytes) -> bool:
|
|||
return ascii_text_bytes / len(data) >= _MIN_SINGLE_BYTE_ASCII_RATIO
|
||||
|
||||
|
||||
def _extract_pdf_text(data: bytes) -> str:
|
||||
"""Extract page-delimited text with the same parser used by RAG ingestion."""
|
||||
from ..rag.parsers import parse_pdf_bytes
|
||||
|
||||
pages, total_pages = parse_pdf_bytes(data, max_pages = _MAX_WEB_PDF_PAGES)
|
||||
page_limit_reached = total_pages > _MAX_WEB_PDF_PAGES
|
||||
parts: list[str] = []
|
||||
length = 0
|
||||
text_limited = False
|
||||
for page in pages:
|
||||
page_text = page.text.strip()
|
||||
if not page_text:
|
||||
continue
|
||||
section = f"## Page {page.page_number}\n\n{page_text}"
|
||||
piece = ("\n\n" if parts else "") + section
|
||||
remaining = _MAX_PAGE_CHARS - length
|
||||
if len(piece) > remaining:
|
||||
parts.append(piece[:remaining])
|
||||
text_limited = True
|
||||
break
|
||||
parts.append(piece)
|
||||
length += len(piece)
|
||||
|
||||
text = "".join(parts).rstrip()
|
||||
if not text:
|
||||
if page_limit_reached:
|
||||
return f"(PDF contains no extractable text in the first {_MAX_WEB_PDF_PAGES} pages)"
|
||||
return ""
|
||||
limits = []
|
||||
if text_limited:
|
||||
limits.append(f"text limited to {_MAX_PAGE_CHARS:,} characters")
|
||||
if page_limit_reached:
|
||||
limits.append(f"page processing capped at {_MAX_WEB_PDF_PAGES} pages")
|
||||
if limits:
|
||||
marker = f"\n\n... (PDF extraction {'; '.join(limits)})"
|
||||
text = text[: _MAX_PAGE_CHARS - len(marker)].rstrip() + marker
|
||||
return text
|
||||
|
||||
|
||||
_USER_AGENTS = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
|
|
@ -4054,29 +4105,71 @@ def _fetch_url_raw(
|
|||
return reason2, "", ""
|
||||
current_host = rp.hostname
|
||||
continue
|
||||
|
||||
# get_content_type() defaults to "text/plain" when the header is
|
||||
# absent (RFC 2045); report "" instead so callers can tell a missing
|
||||
# header apart from a server that really declared text/plain.
|
||||
if resp.headers.get("Content-Type") is None:
|
||||
content_type = ""
|
||||
else:
|
||||
content_type = (resp.headers.get_content_type() or "").lower()
|
||||
|
||||
# Success: read the capped body enforcing the budget between chunks
|
||||
# (see _read_capped_body), so a slow-drip server can't stretch a
|
||||
# single resp.read past the deadline.
|
||||
declared_pdf = content_type == "application/pdf"
|
||||
read_limit = _MAX_PDF_FETCH_BYTES + 1 if declared_pdf else max_bytes
|
||||
body_error, raw_bytes = _read_capped_body(
|
||||
resp,
|
||||
max_bytes,
|
||||
read_limit,
|
||||
timeout,
|
||||
deadline,
|
||||
cancel_event,
|
||||
)
|
||||
if body_error is not None:
|
||||
return body_error, "", ""
|
||||
|
||||
# A missing or wrong PDF MIME type is common: once the initial text-sized
|
||||
# read identifies PDF magic, finish the bounded download to reach the EOF xref.
|
||||
if not declared_pdf and len(raw_bytes) == max_bytes and _has_pdf_magic(raw_bytes):
|
||||
tail_error, tail = _read_capped_body(
|
||||
resp,
|
||||
_MAX_PDF_FETCH_BYTES - max_bytes + 1,
|
||||
timeout,
|
||||
deadline,
|
||||
cancel_event,
|
||||
)
|
||||
if tail_error is not None:
|
||||
return tail_error, "", ""
|
||||
raw_bytes += tail
|
||||
break
|
||||
else:
|
||||
return "Failed to fetch URL: too many redirects.", "", ""
|
||||
|
||||
# get_content_type() defaults to "text/plain" when the header is
|
||||
# absent (RFC 2045); report "" instead so callers can tell a missing
|
||||
# header apart from a server that really declared text/plain.
|
||||
if resp.headers.get("Content-Type") is None:
|
||||
content_type = ""
|
||||
else:
|
||||
content_type = (resp.headers.get_content_type() or "").lower()
|
||||
is_pdf = declared_pdf or _has_pdf_magic(raw_bytes)
|
||||
if is_pdf:
|
||||
if len(raw_bytes) > _MAX_PDF_FETCH_BYTES:
|
||||
return (
|
||||
"(PDF content exceeds the download limit; not readable as text)",
|
||||
"",
|
||||
content_type,
|
||||
)
|
||||
budget_error = _fetch_budget_exceeded(deadline, cancel_event)
|
||||
if budget_error is not None:
|
||||
return budget_error, "", content_type
|
||||
try:
|
||||
pdf_text = _extract_pdf_text(raw_bytes)
|
||||
except Exception as exc:
|
||||
logger.debug("web PDF text extraction failed (%s)", type(exc).__name__)
|
||||
return "(PDF content could not be read as text)", "", content_type
|
||||
budget_error = _fetch_budget_exceeded(deadline, cancel_event)
|
||||
if budget_error is not None:
|
||||
return budget_error, "", content_type
|
||||
if not pdf_text:
|
||||
pdf_text = "(PDF contains no extractable text)"
|
||||
# Report the true type even for a mislabeled body so the caller's "html"
|
||||
# check routes the extracted text to the plain-text path, not html_to_markdown.
|
||||
return None, pdf_text, "application/pdf"
|
||||
|
||||
# Reject known-binary MIME types before decoding. Binary is returned as the
|
||||
# error string so the caller surfaces the placeholder, not replacement chars.
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ def _markdown_incomplete(markdown: str, plain: str) -> bool:
|
|||
return markdown_letters < _PDF_INCOMPLETE_RATIO * plain_letters
|
||||
|
||||
|
||||
def _pdf_markdown(doc) -> list[str] | None:
|
||||
def _pdf_markdown(doc, pages: range | None = None) -> list[str] | None:
|
||||
"""Per-page layout-aware Markdown (tables, headings, lists) via pymupdf4llm; index
|
||||
i maps to page i+1. Returns None when the lib is missing, extraction fails, or the
|
||||
page count does not line up, so the caller falls back to plain PyMuPDF text."""
|
||||
|
|
@ -112,28 +112,44 @@ def _pdf_markdown(doc) -> list[str] | None:
|
|||
except Exception:
|
||||
return None
|
||||
try:
|
||||
chunks = pymupdf4llm.to_markdown(
|
||||
doc,
|
||||
page_chunks = True,
|
||||
show_progress = False,
|
||||
)
|
||||
kwargs = {"page_chunks": True, "show_progress": False}
|
||||
if pages is not None:
|
||||
kwargs["pages"] = list(pages)
|
||||
chunks = pymupdf4llm.to_markdown(doc, **kwargs)
|
||||
except Exception: # noqa: BLE001 - never let Markdown extraction break ingestion
|
||||
logger.warning("pymupdf4llm extraction failed; using plain text", exc_info = True)
|
||||
return None
|
||||
if not isinstance(chunks, list) or len(chunks) != doc.page_count:
|
||||
expected_pages = doc.page_count if pages is None else len(pages)
|
||||
if not isinstance(chunks, list) or len(chunks) != expected_pages:
|
||||
return None
|
||||
return [str(c.get("text") or "") for c in chunks]
|
||||
|
||||
|
||||
def _pdf(path: str, want_images: bool) -> tuple[list[Page], list[ParsedImage]]:
|
||||
def _pdf(
|
||||
source: str | bytes,
|
||||
want_images: bool,
|
||||
max_pages: int | None = None,
|
||||
) -> tuple[list[Page], list[ParsedImage], int]:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
pages: list[Page] = []
|
||||
images: list[ParsedImage] = []
|
||||
doc = fitz.open(path)
|
||||
doc = (
|
||||
fitz.open(stream = source, filetype = "pdf") if isinstance(source, bytes) else fitz.open(source)
|
||||
)
|
||||
try:
|
||||
md = _pdf_markdown(doc) if config.PDF_MARKDOWN else None
|
||||
for i, page in enumerate(doc):
|
||||
if doc.needs_pass:
|
||||
raise ValueError("encrypted PDF requires a password")
|
||||
total_pages = doc.page_count
|
||||
page_numbers = range(total_pages if max_pages is None else min(total_pages, max_pages))
|
||||
if not config.PDF_MARKDOWN:
|
||||
md = None
|
||||
elif max_pages is None:
|
||||
md = _pdf_markdown(doc)
|
||||
else:
|
||||
md = _pdf_markdown(doc, page_numbers)
|
||||
for i, page_number in enumerate(page_numbers):
|
||||
page = doc[page_number]
|
||||
plain = page.get_text("text") or ""
|
||||
candidate = md[i] if md else ""
|
||||
# Prefer layout-aware Markdown (keeps tables/headings legible for retrieval),
|
||||
|
|
@ -147,7 +163,7 @@ def _pdf(path: str, want_images: bool) -> tuple[list[Page], list[ParsedImage]]:
|
|||
text = candidate
|
||||
else:
|
||||
text = plain
|
||||
pages.append(_page(text, i + 1))
|
||||
pages.append(_page(text, page_number + 1))
|
||||
if want_images:
|
||||
for img in page.get_images(full = True):
|
||||
xref = img[0]
|
||||
|
|
@ -161,13 +177,22 @@ def _pdf(path: str, want_images: bool) -> tuple[list[Page], list[ParsedImage]]:
|
|||
images.append(
|
||||
ParsedImage(
|
||||
image_bytes = image_bytes,
|
||||
page_number = i + 1,
|
||||
page_number = page_number + 1,
|
||||
xref = xref,
|
||||
)
|
||||
)
|
||||
finally:
|
||||
doc.close()
|
||||
return pages, images
|
||||
return pages, images, total_pages
|
||||
|
||||
|
||||
def parse_pdf_bytes(data: bytes, *, max_pages: int | None = None) -> tuple[list[Page], int]:
|
||||
"""Extract PDF pages from an in-memory download using the ingestion parser.
|
||||
|
||||
Returns the (capped) pages plus the document's full page count, so a caller
|
||||
that set ``max_pages`` can tell a fully-read short PDF from a truncated one."""
|
||||
pages, _images, total_pages = _pdf(data, want_images = False, max_pages = max_pages)
|
||||
return pages, total_pages
|
||||
|
||||
|
||||
def _merge_rects(boxes: list) -> list:
|
||||
|
|
@ -416,7 +441,7 @@ def parse(path: str, *, want_images: bool = False):
|
|||
ext = os.path.splitext(path)[1].lower()
|
||||
|
||||
if ext == ".pdf":
|
||||
pages, images = _pdf(path, want_images)
|
||||
pages, images, _total = _pdf(path, want_images)
|
||||
return (pages, images) if want_images else pages
|
||||
|
||||
if ext == ".docx":
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import os
|
|||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
|
@ -210,7 +211,9 @@ def finalize_worker_exit(
|
|||
repo_type: Optional[RepoType] = None,
|
||||
repo_id: Optional[str] = None,
|
||||
transport: Optional[str] = None,
|
||||
) -> None:
|
||||
cancel_marker_transport: Optional[str] = None,
|
||||
defer_error: bool = False,
|
||||
) -> str:
|
||||
"""Block until *proc* exits, then record the job's terminal state in
|
||||
*registry*. Drains and scrubs stderr first, then classifies the exit code.
|
||||
A no-op when the process was already dropped (e.g. superseded).
|
||||
|
|
@ -222,7 +225,7 @@ def finalize_worker_exit(
|
|||
rc = proc.wait()
|
||||
cancel_requested = registry.cancel_requested(key)
|
||||
if not registry.drop_process(key, proc):
|
||||
return
|
||||
return "idle"
|
||||
stderr_text = download_registry.scrub_secrets(
|
||||
(stderr_data or b"").decode("utf-8", "replace").strip(),
|
||||
hf_token = hf_token,
|
||||
|
|
@ -230,6 +233,8 @@ def finalize_worker_exit(
|
|||
state = classify_exit(rc, cancel_requested = cancel_requested)
|
||||
if state == "complete":
|
||||
registry.set_job(key, "complete")
|
||||
if transport == download_registry.TRANSPORT_HTTP:
|
||||
registry.update_job_transport(key, download_registry.TRANSPORT_HTTP)
|
||||
if stderr_text:
|
||||
if download_manifest.MANIFEST_DEGRADED_MARKER in stderr_text:
|
||||
logger.warning(
|
||||
|
|
@ -262,18 +267,226 @@ def finalize_worker_exit(
|
|||
metadata.variant
|
||||
if metadata is not None and metadata.variant
|
||||
else download_registry.variant_from_key(key),
|
||||
transport,
|
||||
cancel_marker_transport or transport,
|
||||
logger = logger,
|
||||
)
|
||||
else:
|
||||
registry.set_job(
|
||||
key,
|
||||
"error",
|
||||
stderr_text or f"worker exited with code {rc}",
|
||||
)
|
||||
if not defer_error:
|
||||
registry.set_job(
|
||||
key,
|
||||
"error",
|
||||
stderr_text or f"worker exited with code {rc}",
|
||||
)
|
||||
logger.error(
|
||||
f"{log_prefix} failed for {label} (rc={rc}): {stderr_text}",
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _set_retry_failure_state(
|
||||
registry: download_registry.DownloadRegistry,
|
||||
key: str,
|
||||
error: str,
|
||||
*,
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
fallback_variant: Optional[str],
|
||||
fallback_transport: Optional[str],
|
||||
logger,
|
||||
) -> str:
|
||||
state, metadata = registry.set_error_unless_cancelled(key, error)
|
||||
if state == "cancelled":
|
||||
download_registry.persist_cancel_marker(
|
||||
repo_type,
|
||||
repo_id,
|
||||
metadata.variant if metadata is not None and metadata.variant else fallback_variant,
|
||||
metadata.transport
|
||||
if metadata is not None and metadata.transport
|
||||
else fallback_transport,
|
||||
logger = logger,
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _try_http_retry(
|
||||
registry: download_registry.DownloadRegistry,
|
||||
key: str,
|
||||
*,
|
||||
hf_token: Optional[str],
|
||||
label: str,
|
||||
log_prefix: str,
|
||||
logger,
|
||||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
watch_name: str,
|
||||
) -> bool:
|
||||
"""Reclaim *key* with HTTP transport and spawn a recovery worker.
|
||||
|
||||
Returns ``True`` when the HTTP worker was successfully registered.
|
||||
Caller is responsible for ensuring this is only called when: the job is
|
||||
in ``"error"`` state, the original transport was XET, and HTTP is available.
|
||||
|
||||
Derives variant and blob-hash metadata from the registry entry written by
|
||||
the original XET claim so callers do not re-construct worker arguments.
|
||||
Re-queries peer protection hashes at spawn time to reflect any concurrent
|
||||
sibling changes between the XET failure and this call.
|
||||
"""
|
||||
original_metadata = registry.get_job_metadata(key)
|
||||
if original_metadata is None:
|
||||
logger.debug("%s XET retry skipped for %s; metadata unavailable", log_prefix, label)
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
"XET retry skipped: metadata unavailable",
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = download_registry.variant_from_key(key),
|
||||
fallback_transport = download_registry.TRANSPORT_XET,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
if original_metadata.transport != download_registry.TRANSPORT_XET:
|
||||
logger.debug(
|
||||
"%s XET retry skipped for %s; original transport was %s",
|
||||
log_prefix,
|
||||
label,
|
||||
original_metadata.transport,
|
||||
)
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
f"XET retry skipped: original transport was {original_metadata.transport}",
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = original_metadata.variant,
|
||||
fallback_transport = original_metadata.transport,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
variant = original_metadata.variant
|
||||
blob_hashes = original_metadata.blob_hashes
|
||||
progress_blob_hashes = original_metadata.progress_blob_hashes
|
||||
completed_baseline_bytes = (
|
||||
download_registry.completed_blob_bytes(
|
||||
repo_type,
|
||||
repo_id,
|
||||
progress_blob_hashes,
|
||||
)
|
||||
if progress_blob_hashes
|
||||
else 0
|
||||
)
|
||||
generation = registry.current_generation(key)
|
||||
registry.release_active_slot(key)
|
||||
while True:
|
||||
if registry.cancel_requested(key):
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
"HTTP retry cancelled before reclaiming the download slot",
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = variant,
|
||||
fallback_transport = original_metadata.transport,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
|
||||
claimed, conflict_state = registry.claim(
|
||||
key,
|
||||
download_registry.TRANSPORT_HTTP,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
variant = variant,
|
||||
blob_hashes = blob_hashes,
|
||||
progress_blob_hashes = progress_blob_hashes,
|
||||
completed_baseline_bytes = completed_baseline_bytes,
|
||||
generation = generation,
|
||||
replace_active = True,
|
||||
cancel_marker_transport = original_metadata.transport,
|
||||
)
|
||||
if claimed:
|
||||
break
|
||||
if conflict_state == "deleting":
|
||||
logger.debug(
|
||||
"%s XET retry claim rejected for %s; repo is being deleted",
|
||||
log_prefix,
|
||||
label,
|
||||
)
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
"HTTP retry could not reclaim the download slot",
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = variant,
|
||||
fallback_transport = original_metadata.transport,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
logger.debug(
|
||||
"%s XET retry claim blocked for %s by active sibling state %s; waiting",
|
||||
log_prefix,
|
||||
label,
|
||||
conflict_state,
|
||||
)
|
||||
time.sleep(0.05)
|
||||
|
||||
args: list[str] = ["--repo-id", repo_id]
|
||||
if repo_type == "dataset":
|
||||
args.append("--dataset")
|
||||
elif variant:
|
||||
args.extend(["--variant", variant])
|
||||
|
||||
# Re-query at spawn time: sibling state may have changed since XET failed.
|
||||
peer_hashes = registry.peer_blob_hashes(key) if variant else frozenset()
|
||||
|
||||
logger.warning(
|
||||
"%s XET worker failed for %s; retrying over HTTP",
|
||||
log_prefix,
|
||||
label,
|
||||
)
|
||||
try:
|
||||
proc = spawn_worker(
|
||||
args,
|
||||
hf_token,
|
||||
use_xet = False,
|
||||
protected_blob_hashes = peer_hashes or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
scrubbed = download_registry.scrub_secrets(str(exc), hf_token = hf_token)
|
||||
logger.error(
|
||||
"%s HTTP retry spawn failed for %s: %s",
|
||||
log_prefix,
|
||||
label,
|
||||
scrubbed,
|
||||
)
|
||||
registry.update_job_transport(key, original_metadata.transport)
|
||||
_set_retry_failure_state(
|
||||
registry,
|
||||
key,
|
||||
scrubbed,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
fallback_variant = variant,
|
||||
fallback_transport = original_metadata.transport,
|
||||
logger = logger,
|
||||
)
|
||||
return False
|
||||
|
||||
return register_worker(
|
||||
registry,
|
||||
key,
|
||||
proc,
|
||||
hf_token = hf_token,
|
||||
label = label,
|
||||
log_prefix = log_prefix,
|
||||
logger = logger,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
transport = download_registry.TRANSPORT_HTTP,
|
||||
cancel_marker_transport = original_metadata.transport,
|
||||
watch_name = watch_name,
|
||||
)
|
||||
|
||||
|
||||
def kill_and_reap_process(
|
||||
|
|
@ -309,6 +522,7 @@ def register_worker(
|
|||
repo_type: RepoType,
|
||||
repo_id: str,
|
||||
transport: str,
|
||||
cancel_marker_transport: Optional[str] = None,
|
||||
watch_name: str,
|
||||
) -> bool:
|
||||
if not registry.register_process(key, proc):
|
||||
|
|
@ -319,7 +533,14 @@ def register_worker(
|
|||
|
||||
def _watch() -> None:
|
||||
try:
|
||||
finalize_worker_exit(
|
||||
can_retry_http = (
|
||||
transport == download_registry.TRANSPORT_XET
|
||||
and download_registry.download_transport_unavailable_reason(
|
||||
download_registry.TRANSPORT_HTTP
|
||||
)
|
||||
is None
|
||||
)
|
||||
state = finalize_worker_exit(
|
||||
registry,
|
||||
key,
|
||||
proc,
|
||||
|
|
@ -330,7 +551,25 @@ def register_worker(
|
|||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
transport = transport,
|
||||
cancel_marker_transport = cancel_marker_transport,
|
||||
defer_error = can_retry_http,
|
||||
)
|
||||
# XET-to-HTTP recovery: when a non-cancelled XET worker fails and
|
||||
# HTTP is available, attempt one automatic retry over HTTP. The
|
||||
# transport check is the recursion guard: an HTTP worker that errors
|
||||
# never satisfies `transport == TRANSPORT_XET`, so it stays terminal.
|
||||
if can_retry_http and state == "error":
|
||||
_try_http_retry(
|
||||
registry,
|
||||
key,
|
||||
hf_token = worker_token,
|
||||
label = label,
|
||||
log_prefix = log_prefix,
|
||||
logger = logger,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
watch_name = watch_name,
|
||||
)
|
||||
except Exception:
|
||||
# finalize_worker_exit is the only thing that clears running/cancelling;
|
||||
# if it raises, force a terminal state so claim() isn't blocked until restart.
|
||||
|
|
@ -426,8 +665,19 @@ def cancel_worker(
|
|||
return "cancelling"
|
||||
return registry.get_job(key).state
|
||||
# Worker already exited; let its watcher classify the real return code.
|
||||
# Arming a pending cancel here could mislabel a genuine failure as a cancel.
|
||||
if proc.poll() is not None:
|
||||
get_metadata = getattr(registry, "get_job_metadata", None)
|
||||
metadata = get_metadata(key) if get_metadata is not None else None
|
||||
can_retry_http = (
|
||||
metadata is not None
|
||||
and metadata.transport == download_registry.TRANSPORT_XET
|
||||
and download_registry.download_transport_unavailable_reason(
|
||||
download_registry.TRANSPORT_HTTP
|
||||
)
|
||||
is None
|
||||
)
|
||||
if can_retry_http and registry.mark_pending_cancel(key, generation):
|
||||
return "cancelling"
|
||||
return registry.get_job(key).state
|
||||
|
||||
if not registry.request_cancel(key, proc, generation):
|
||||
|
|
|
|||
|
|
@ -60,6 +60,30 @@ def _job_status(
|
|||
return DownloadJobStatus(state = state, error = error, generation = generation)
|
||||
|
||||
|
||||
def _load_in_flight(repo_id: str) -> bool:
|
||||
try:
|
||||
from core.inference.llama_cpp import hf_gguf_load_in_flight
|
||||
return hf_gguf_load_in_flight(repo_id)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _load_in_flight_error(repo_id: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code = 409,
|
||||
detail = (
|
||||
f"A model load for '{repo_id}' is in progress and may be "
|
||||
"downloading it. Wait for the load to finish (or cancel it), "
|
||||
"then start the download."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _reject_if_load_in_flight(repo_id: str) -> None:
|
||||
if _load_in_flight(repo_id):
|
||||
raise _load_in_flight_error(repo_id)
|
||||
|
||||
|
||||
def _spawn_download_worker(
|
||||
repo_id: str,
|
||||
variant: Optional[str],
|
||||
|
|
@ -89,6 +113,9 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
|
|||
# Canonicalize so two different-cased paste-ins share one job + cache dir.
|
||||
repo_id = await asyncio.to_thread(resolve_cached_repo_id_case, repo_id, repo_type = "model")
|
||||
|
||||
# Avoid concurrent writers to the same HF cache files.
|
||||
_reject_if_load_in_flight(repo_id)
|
||||
|
||||
variant = (body.gguf_variant or "").strip() or None
|
||||
if variant is not None and not _is_valid_gguf_variant(variant):
|
||||
raise HTTPException(
|
||||
|
|
@ -147,9 +174,12 @@ async def download_model_response(body: DownloadModelRequest, hf_token: Optional
|
|||
blob_hashes = variant_blob_hashes,
|
||||
progress_blob_hashes = variant_progress_blob_hashes,
|
||||
completed_baseline_bytes = completed_baseline_bytes,
|
||||
admission_check = lambda: not _load_in_flight(repo_id),
|
||||
)
|
||||
generation = _registry.current_generation(key)
|
||||
if not claimed:
|
||||
if claim_state == "admission_blocked":
|
||||
raise _load_in_flight_error(repo_id)
|
||||
# claim_state is the blocking job's state. The client can attach only
|
||||
# when the blocker is this key's own in-flight job (adoptable); a
|
||||
# cross-variant conflict or in-progress delete is not accepted.
|
||||
|
|
|
|||
|
|
@ -1,27 +1,147 @@
|
|||
# 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 io
|
||||
import logging
|
||||
|
||||
from hub.services import download_lifecycle
|
||||
from hub.utils import download_registry, state_dir
|
||||
|
||||
|
||||
def _set_xet_reason(monkeypatch, reason):
|
||||
class _Proc:
|
||||
pid = 4242
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rc,
|
||||
stderr = b"",
|
||||
):
|
||||
self.rc = rc
|
||||
self.stderr = io.BytesIO(stderr)
|
||||
self.waited = False
|
||||
|
||||
def poll(self):
|
||||
return self.rc if self.waited else None
|
||||
|
||||
def wait(self, timeout = None):
|
||||
self.waited = True
|
||||
return self.rc
|
||||
|
||||
def kill(self):
|
||||
pass
|
||||
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, *, target, **_kwargs):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
self.target()
|
||||
|
||||
|
||||
def test_resolve_effective_use_xet(monkeypatch):
|
||||
for requested, unavailable_reason, expected in (
|
||||
(False, "unused", False),
|
||||
(True, None, True),
|
||||
(True, "hf_xet is not installed", False),
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
download_lifecycle.download_registry,
|
||||
"download_transport_unavailable_reason",
|
||||
lambda _transport, reason = unavailable_reason: reason,
|
||||
)
|
||||
assert download_lifecycle.resolve_effective_use_xet(requested) is expected
|
||||
|
||||
|
||||
def test_xet_failure_retries_over_http_for_model_and_dataset(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
monkeypatch.setattr(download_lifecycle.threading, "Thread", _ImmediateThread)
|
||||
register_worker = download_lifecycle.register_worker
|
||||
|
||||
for repo_type, repo_id, variant, expected_args in (
|
||||
("model", "Org/Model", "Q4_K_M", ["--repo-id", "Org/Model", "--variant", "Q4_K_M"]),
|
||||
("dataset", "Org/Data", None, ["--repo-id", "Org/Data", "--dataset"]),
|
||||
):
|
||||
registry = download_registry.DownloadRegistry()
|
||||
key = download_registry.normalize_job_key(f"{repo_id}::{variant}" if variant else repo_id)
|
||||
assert registry.claim(
|
||||
key,
|
||||
download_registry.TRANSPORT_XET,
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
variant = variant,
|
||||
blob_hashes = frozenset({"blob"}),
|
||||
)[0]
|
||||
generation = registry.current_generation(key)
|
||||
spawned = []
|
||||
|
||||
def fake_spawn(
|
||||
args,
|
||||
_token,
|
||||
*,
|
||||
use_xet,
|
||||
protected_blob_hashes = None,
|
||||
):
|
||||
spawned.append((args, use_xet, protected_blob_hashes))
|
||||
return _Proc(0)
|
||||
|
||||
def fake_retry_register(*_args, **kwargs):
|
||||
assert kwargs["transport"] == download_registry.TRANSPORT_HTTP
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(download_lifecycle, "spawn_worker", fake_spawn)
|
||||
monkeypatch.setattr(download_lifecycle, "register_worker", fake_retry_register)
|
||||
assert register_worker(
|
||||
registry,
|
||||
key,
|
||||
_Proc(1, b"xet failed"),
|
||||
hf_token = None,
|
||||
label = repo_id,
|
||||
log_prefix = "Download",
|
||||
logger = logging.getLogger("test"),
|
||||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
transport = download_registry.TRANSPORT_XET,
|
||||
watch_name = f"{repo_type}-watch",
|
||||
)
|
||||
|
||||
metadata = registry.get_job_metadata(key)
|
||||
assert spawned == [(expected_args, False, None)]
|
||||
assert metadata.transport == download_registry.TRANSPORT_HTTP
|
||||
assert metadata.blob_hashes == frozenset({"blob"})
|
||||
assert registry.current_generation(key) == generation
|
||||
|
||||
|
||||
def test_http_failure_remains_terminal(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(state_dir, "cache_root", lambda: tmp_path / "state")
|
||||
monkeypatch.setattr(download_lifecycle.threading, "Thread", _ImmediateThread)
|
||||
register_worker = download_lifecycle.register_worker
|
||||
registry = download_registry.DownloadRegistry()
|
||||
key = download_registry.normalize_repo_key("Org/Data")
|
||||
assert registry.claim(
|
||||
key,
|
||||
download_registry.TRANSPORT_HTTP,
|
||||
repo_type = "dataset",
|
||||
repo_id = "Org/Data",
|
||||
)[0]
|
||||
monkeypatch.setattr(
|
||||
download_lifecycle.download_registry,
|
||||
"download_transport_unavailable_reason",
|
||||
lambda _transport: reason,
|
||||
download_lifecycle,
|
||||
"register_worker",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("HTTP failures must not retry")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_effective_use_xet_keeps_http_when_not_requested(monkeypatch):
|
||||
_set_xet_reason(monkeypatch, "should not be consulted")
|
||||
assert download_lifecycle.resolve_effective_use_xet(False) is False
|
||||
|
||||
|
||||
def test_resolve_effective_use_xet_keeps_xet_when_available(monkeypatch):
|
||||
_set_xet_reason(monkeypatch, None)
|
||||
assert download_lifecycle.resolve_effective_use_xet(True) is True
|
||||
|
||||
|
||||
def test_resolve_effective_use_xet_downgrades_when_xet_unavailable(monkeypatch):
|
||||
_set_xet_reason(monkeypatch, "Xet transport is unavailable because hf_xet is not installed.")
|
||||
assert download_lifecycle.resolve_effective_use_xet(True) is False
|
||||
assert register_worker(
|
||||
registry,
|
||||
key,
|
||||
_Proc(1, b"http failed"),
|
||||
hf_token = None,
|
||||
label = "Org/Data",
|
||||
log_prefix = "Download",
|
||||
logger = logging.getLogger("test"),
|
||||
repo_type = "dataset",
|
||||
repo_id = "Org/Data",
|
||||
transport = download_registry.TRANSPORT_HTTP,
|
||||
watch_name = "dataset-watch",
|
||||
)
|
||||
assert registry.get_job(key).state == "error"
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ import sys
|
|||
import threading
|
||||
import time
|
||||
import weakref
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Literal, Optional
|
||||
from typing import Callable, Iterator, Literal, Optional
|
||||
|
||||
from loggers import get_logger
|
||||
|
||||
|
|
@ -126,6 +126,9 @@ def write_worker_breadcrumb(key: str, pid: int, metadata: Optional["DownloadMeta
|
|||
"repo_id": metadata.repo_id if metadata is not None else None,
|
||||
"variant": metadata.variant if metadata is not None else None,
|
||||
"transport": metadata.transport if metadata is not None else None,
|
||||
"cancel_marker_transport": metadata.cancel_marker_transport
|
||||
if metadata is not None
|
||||
else None,
|
||||
}
|
||||
tmp = path.with_name(f".{path.name}.tmp-{pid}")
|
||||
try:
|
||||
|
|
@ -305,7 +308,7 @@ def reap_orphan_workers() -> None:
|
|||
data.get("repo_type"),
|
||||
repo_id,
|
||||
data.get("variant"),
|
||||
data.get("transport"),
|
||||
data.get("cancel_marker_transport") or data.get("transport"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Reaper failed for breadcrumb %s: %s", entry, exc)
|
||||
|
|
@ -699,6 +702,7 @@ class DownloadMetadata:
|
|||
repo_id: str
|
||||
variant: Optional[str]
|
||||
transport: Optional[str]
|
||||
cancel_marker_transport: Optional[str] = None
|
||||
# GGUF variant main/writable hashes, identifying the variant-specific shards
|
||||
# for concurrency decisions.
|
||||
blob_hashes: frozenset[str] = field(default_factory = frozenset)
|
||||
|
|
@ -801,6 +805,7 @@ class DownloadRegistry:
|
|||
self._processes: dict[str, subprocess.Popen] = {}
|
||||
self._repo_active: dict[str, set[str]] = {}
|
||||
self._metadata: dict[str, DownloadMetadata] = {}
|
||||
self._cancel_marker_transports: dict[str, str] = {}
|
||||
self._pending_cancel: dict[str, Optional[int]] = {}
|
||||
self._generations: dict[str, int] = {}
|
||||
# Monotonic across keys so an evicted then re-claimed key never reuses a
|
||||
|
|
@ -839,6 +844,7 @@ class DownloadRegistry:
|
|||
if state in TERMINAL_STATES:
|
||||
self._put_terminal_job_locked(key, state, error)
|
||||
self._pending_cancel.pop(key, None)
|
||||
self._cancel_marker_transports.pop(key, None)
|
||||
repo = _repo_of_key(key)
|
||||
active = self._repo_active.get(repo)
|
||||
if active is not None:
|
||||
|
|
@ -848,6 +854,57 @@ class DownloadRegistry:
|
|||
else:
|
||||
self._jobs[key] = DownloadState(state, error)
|
||||
|
||||
def set_error_unless_cancelled(
|
||||
self, key: str, error: str
|
||||
) -> tuple[JobState, Optional[DownloadMetadata]]:
|
||||
key = normalize_job_key(key)
|
||||
with self._lock:
|
||||
current = self._jobs.get(key, DownloadState("idle")).state
|
||||
has_pending_cancel = key in self._pending_cancel
|
||||
pending_generation = self._pending_cancel.get(key)
|
||||
metadata = self._metadata.get(key)
|
||||
should_cancel = current == "cancelling" or (
|
||||
has_pending_cancel and self._generation_matches_locked(key, pending_generation)
|
||||
)
|
||||
terminal_state: JobState = "cancelled" if should_cancel else "error"
|
||||
marker_transport = self._cancel_marker_transports.pop(key, None)
|
||||
if marker_transport is None and metadata is not None:
|
||||
marker_transport = metadata.cancel_marker_transport
|
||||
self._put_terminal_job_locked(
|
||||
key,
|
||||
terminal_state,
|
||||
None if should_cancel else error,
|
||||
)
|
||||
self._pending_cancel.pop(key, None)
|
||||
repo = _repo_of_key(key)
|
||||
active = self._repo_active.get(repo)
|
||||
if active is not None:
|
||||
active.discard(key)
|
||||
if not active:
|
||||
self._repo_active.pop(repo, None)
|
||||
if should_cancel and metadata is not None and marker_transport is not None:
|
||||
metadata = replace(metadata, transport = marker_transport)
|
||||
return terminal_state, metadata
|
||||
|
||||
def update_job_transport(self, key: str, transport: str) -> None:
|
||||
key = normalize_job_key(key)
|
||||
with self._lock:
|
||||
metadata = self._metadata.get(key)
|
||||
if metadata is None or metadata.transport == transport:
|
||||
return
|
||||
self._metadata[key] = replace(metadata, transport = transport)
|
||||
|
||||
def release_active_slot(self, key: str) -> None:
|
||||
key = normalize_job_key(key)
|
||||
repo = _repo_of_key(key)
|
||||
with self._lock:
|
||||
active = self._repo_active.get(repo)
|
||||
if active is None:
|
||||
return
|
||||
active.discard(key)
|
||||
if not active:
|
||||
self._repo_active.pop(repo, None)
|
||||
|
||||
def get_job(self, key: str) -> DownloadState:
|
||||
key = normalize_job_key(key)
|
||||
with self._lock:
|
||||
|
|
@ -884,6 +941,14 @@ class DownloadRegistry:
|
|||
):
|
||||
self._put_terminal_job_locked(key, "cancelled")
|
||||
metadata_to_persist = self._metadata.pop(key, None)
|
||||
marker_transport = self._cancel_marker_transports.pop(key, None)
|
||||
if marker_transport is None and metadata_to_persist is not None:
|
||||
marker_transport = metadata_to_persist.cancel_marker_transport
|
||||
if metadata_to_persist is not None and marker_transport is not None:
|
||||
metadata_to_persist = replace(
|
||||
metadata_to_persist,
|
||||
transport = marker_transport,
|
||||
)
|
||||
repo = _repo_of_key(key)
|
||||
active = self._repo_active.get(repo)
|
||||
if active is not None:
|
||||
|
|
@ -963,12 +1028,24 @@ class DownloadRegistry:
|
|||
blob_hashes: Optional[frozenset[str]] = None,
|
||||
progress_blob_hashes: Optional[frozenset[str]] = None,
|
||||
completed_baseline_bytes: int = 0,
|
||||
admission_check: Optional[Callable[[], bool]] = None,
|
||||
generation: Optional[int] = None,
|
||||
replace_active: bool = False,
|
||||
metadata_transport: Optional[str] = None,
|
||||
cancel_marker_transport: Optional[str] = None,
|
||||
) -> tuple[bool, str]:
|
||||
key = normalize_job_key(key)
|
||||
repo = _repo_of_key(key)
|
||||
requested_hashes = blob_hashes or frozenset()
|
||||
requested_progress_hashes = progress_blob_hashes or frozenset()
|
||||
with self._lock:
|
||||
# Run the final external admission check while the registry lock is
|
||||
# held, immediately before inspecting and publishing active state.
|
||||
# The GGUF load path establishes its marker before calling
|
||||
# its active-job probe, so either this claim observes that marker
|
||||
# or the load's later probe observes this claim.
|
||||
if admission_check is not None and not admission_check():
|
||||
return False, "admission_blocked"
|
||||
deleting_scopes = self._deleting.get(repo)
|
||||
if deleting_scopes is not None and (
|
||||
None in deleting_scopes or variant_from_key(key) in deleting_scopes
|
||||
|
|
@ -1007,10 +1084,13 @@ class DownloadRegistry:
|
|||
if conflict_state is not None:
|
||||
return False, conflict_state
|
||||
current = self._jobs.get(key, DownloadState("idle")).state
|
||||
if current in _ACTIVE_STATES:
|
||||
if current in _ACTIVE_STATES and not replace_active:
|
||||
return False, current
|
||||
self._generation_seq += 1
|
||||
self._generations[key] = self._generation_seq
|
||||
if generation is None:
|
||||
self._generation_seq += 1
|
||||
self._generations[key] = self._generation_seq
|
||||
else:
|
||||
self._generations[key] = generation
|
||||
self._jobs[key] = DownloadState("running")
|
||||
self._repo_active.setdefault(repo, active).add(key)
|
||||
if repo_type and repo_id:
|
||||
|
|
@ -1018,7 +1098,8 @@ class DownloadRegistry:
|
|||
repo_type = repo_type,
|
||||
repo_id = repo_id,
|
||||
variant = variant,
|
||||
transport = transport,
|
||||
transport = metadata_transport if metadata_transport is not None else transport,
|
||||
cancel_marker_transport = cancel_marker_transport,
|
||||
blob_hashes = requested_hashes,
|
||||
progress_blob_hashes = requested_progress_hashes,
|
||||
completed_baseline_bytes = max(
|
||||
|
|
@ -1026,8 +1107,13 @@ class DownloadRegistry:
|
|||
int(completed_baseline_bytes or 0),
|
||||
),
|
||||
)
|
||||
if cancel_marker_transport is not None:
|
||||
self._cancel_marker_transports[key] = cancel_marker_transport
|
||||
else:
|
||||
self._cancel_marker_transports.pop(key, None)
|
||||
else:
|
||||
self._metadata.pop(key, None)
|
||||
self._cancel_marker_transports.pop(key, None)
|
||||
return True, "running"
|
||||
|
||||
def adoptable(self, key: str) -> bool:
|
||||
|
|
@ -1053,7 +1139,8 @@ class DownloadRegistry:
|
|||
download. A variant delete conflicts only with that same variant or a
|
||||
whole-repo download writing the shared snapshot; other quantizations
|
||||
download concurrently and never block it."""
|
||||
for key in self._repo_active.get(repo_id, set()):
|
||||
active_keys = self._repo_active.get(repo_id, set())
|
||||
for key in active_keys:
|
||||
job = self._jobs.get(key)
|
||||
if job is None or job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
|
|
@ -1062,6 +1149,16 @@ class DownloadRegistry:
|
|||
other_variant = self._active_job_variant_locked(key)
|
||||
if other_variant is None or other_variant == variant:
|
||||
return True
|
||||
for key, job in self._jobs.items():
|
||||
if key in active_keys or _repo_of_key(key) != repo_id:
|
||||
continue
|
||||
if job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if variant is None:
|
||||
return True
|
||||
other_variant = self._active_job_variant_locked(key)
|
||||
if other_variant is None or other_variant == variant:
|
||||
return True
|
||||
return False
|
||||
|
||||
def peer_blob_hashes(self, key: str) -> frozenset[str]:
|
||||
|
|
@ -1108,6 +1205,16 @@ class DownloadRegistry:
|
|||
candidate_keys = list(self._repo_active.get(repo_key, set()))
|
||||
else:
|
||||
candidate_keys = [key for active in self._repo_active.values() for key in active]
|
||||
# An XET->HTTP retry handoff briefly drops its key from _repo_active
|
||||
# while its job stays active; include those released-but-active jobs
|
||||
# so the waiting retry still lists and can be adopted or cancelled.
|
||||
seen = set(candidate_keys)
|
||||
for key, job in self._jobs.items():
|
||||
if key in seen or job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if repo_key is not None and _repo_of_key(key) != repo_key:
|
||||
continue
|
||||
candidate_keys.append(key)
|
||||
refs: list[ActiveDownloadRef] = []
|
||||
for key in candidate_keys:
|
||||
job = self._jobs.get(key)
|
||||
|
|
@ -1123,6 +1230,23 @@ class DownloadRegistry:
|
|||
)
|
||||
return refs
|
||||
|
||||
def has_active_variant(self, repo_id: str, variant: Optional[str]) -> bool:
|
||||
"""Whether an active model job targets this exact GGUF variant.
|
||||
|
||||
Scans the job table rather than only ``_repo_active`` so an XET-to-HTTP
|
||||
retry handoff remains visible while it has temporarily released its
|
||||
active slot.
|
||||
"""
|
||||
repo_key = normalize_repo_key(repo_id)
|
||||
target = (variant or "").strip().lower() or None
|
||||
with self._lock:
|
||||
for key, job in self._jobs.items():
|
||||
if _repo_of_key(key) != repo_key or job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if self._active_job_variant_locked(key) == target:
|
||||
return True
|
||||
return False
|
||||
|
||||
def begin_delete(
|
||||
self,
|
||||
repo_id: str,
|
||||
|
|
@ -1169,12 +1293,25 @@ class DownloadRegistry:
|
|||
repo_id = normalize_repo_key(repo_id)
|
||||
target = (variant or "").strip().lower() or None
|
||||
with self._lock:
|
||||
for key in self._repo_active.get(repo_id, set()):
|
||||
active_keys = self._repo_active.get(repo_id, set())
|
||||
for key in active_keys:
|
||||
job = self._jobs.get(key)
|
||||
if job is None or job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if self._active_job_variant_locked(key) != target:
|
||||
return True
|
||||
# An XET->HTTP retry peer between release_active_slot() and its reclaim
|
||||
# is briefly absent from _repo_active while its job stays active and
|
||||
# still owns the shared companion; mirror the released-but-active scan
|
||||
# used by _delete_blocked_by_active_locked so it still blocks companion
|
||||
# deletion of a different variant.
|
||||
for key, job in self._jobs.items():
|
||||
if key in active_keys or _repo_of_key(key) != repo_id:
|
||||
continue
|
||||
if job.state not in _ACTIVE_STATES:
|
||||
continue
|
||||
if self._active_job_variant_locked(key) != target:
|
||||
return True
|
||||
return False
|
||||
|
||||
def request_cancel(
|
||||
|
|
@ -1198,17 +1335,58 @@ class DownloadRegistry:
|
|||
return True
|
||||
|
||||
def terminate_all(self, kind: str = "download") -> None:
|
||||
settled_no_proc: list[Optional[DownloadMetadata]] = []
|
||||
with self._lock:
|
||||
live = [
|
||||
(key, proc, self._metadata.get(key))
|
||||
for key, proc in self._processes.items()
|
||||
if proc.poll() is None
|
||||
]
|
||||
live_keys = {key for key, _proc, _metadata in live}
|
||||
# Flag as an intentional stop so the watcher's exit classification
|
||||
# reports them cancelled rather than an OOM/crash once SIGKILL lands.
|
||||
for key, _proc, _metadata in live:
|
||||
if self._jobs.get(key, DownloadState("idle")).state == "running":
|
||||
self._jobs[key] = DownloadState("cancelling")
|
||||
# Settle active jobs without a live worker too. Two cases: an
|
||||
# XET->HTTP retry parked in the reclaim wait loop has dropped its
|
||||
# worker and slot guard, so it is absent from `live`; and a
|
||||
# registered worker that already exited with an error but whose
|
||||
# watcher has not yet run would otherwise stay `running` and spawn an
|
||||
# HTTP retry after this shutdown snapshot. Skip a registered worker
|
||||
# that exited cleanly (rc == 0): it completed and the watcher will
|
||||
# mark it done, so marking it cancelling would strand a stale marker.
|
||||
for key, job in list(self._jobs.items()):
|
||||
if job.state not in _ACTIVE_STATES or key in live_keys:
|
||||
continue
|
||||
proc = self._processes.get(key)
|
||||
if proc is not None:
|
||||
if proc.poll() == 0:
|
||||
continue
|
||||
# A registered worker that exited nonzero on its own over HTTP
|
||||
# is a genuine terminal download failure, not a shutdown cancel
|
||||
# and not retry-capable: leave its error status intact rather
|
||||
# than persisting a cancel marker that would read as
|
||||
# cancelled/resumable after restart. Only an exited XET worker
|
||||
# could still spawn a post-shutdown HTTP retry, so only that
|
||||
# needs settling here.
|
||||
metadata = self._metadata.get(key)
|
||||
if metadata is not None and metadata.transport == TRANSPORT_HTTP:
|
||||
continue
|
||||
self._pending_cancel[key] = self._generations.get(key)
|
||||
self._jobs[key] = DownloadState("cancelling")
|
||||
settled_no_proc.append(self._metadata.get(key))
|
||||
# Persist a cancel marker for each settled no-live-worker job outside the
|
||||
# lock (mirroring the reaped path) so shutdown records resumable/cancelled
|
||||
# state even if it returns before the daemon watcher wakes to do so.
|
||||
for metadata in settled_no_proc:
|
||||
if metadata is not None:
|
||||
persist_cancel_marker(
|
||||
metadata.repo_type,
|
||||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
)
|
||||
reaped: list[tuple[str, subprocess.Popen, Optional[DownloadMetadata]]] = []
|
||||
for key, proc, metadata in live:
|
||||
try:
|
||||
|
|
@ -1222,7 +1400,7 @@ class DownloadRegistry:
|
|||
metadata.repo_type,
|
||||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.transport,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
)
|
||||
continue
|
||||
reaped.append((key, proc, metadata))
|
||||
|
|
@ -1242,7 +1420,7 @@ class DownloadRegistry:
|
|||
metadata.repo_type,
|
||||
metadata.repo_id,
|
||||
metadata.variant,
|
||||
metadata.transport,
|
||||
metadata.cancel_marker_transport or metadata.transport,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -612,6 +612,22 @@ app = FastAPI(
|
|||
lifespan = lifespan,
|
||||
)
|
||||
|
||||
# The MCP surface is opt-in because it can start GPU jobs and write model
|
||||
# artifacts. Mount it only when explicitly enabled by the Studio process.
|
||||
if os.environ.get("UNSLOTH_STUDIO_ENABLE_MCP") == "1":
|
||||
from fastmcp.utilities.lifespan import combine_lifespans
|
||||
|
||||
from mcp_server import BearerTokenMiddleware, create_studio_mcp
|
||||
|
||||
_studio_mcp_app = create_studio_mcp().http_app(path = "/")
|
||||
_studio_mcp_lifespan = _studio_mcp_app.lifespan
|
||||
_mcp_token = os.environ.get("UNSLOTH_STUDIO_MCP_TOKEN")
|
||||
if not _mcp_token:
|
||||
raise RuntimeError("UNSLOTH_STUDIO_MCP_TOKEN is required when MCP is enabled")
|
||||
_studio_mcp_app = BearerTokenMiddleware(_studio_mcp_app, _mcp_token)
|
||||
app.router.lifespan_context = combine_lifespans(lifespan, _studio_mcp_lifespan)
|
||||
app.mount("/mcp", _studio_mcp_app)
|
||||
|
||||
from loggers.config import LogConfig
|
||||
from loggers.handlers import LoggingMiddleware
|
||||
|
||||
|
|
@ -752,6 +768,7 @@ _BODY_PROTECTED_PREFIXES = (
|
|||
"/api/settings",
|
||||
"/api/train",
|
||||
"/api/export",
|
||||
"/mcp",
|
||||
)
|
||||
_DATASET_UPLOAD_PASSTHROUGH_PREFIX = "/api/datasets/upload"
|
||||
_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX = (
|
||||
|
|
|
|||
259
studio/backend/mcp_server.py
Normal file
259
studio/backend/mcp_server.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Curated MCP tools for driving an Unsloth Studio instance.
|
||||
|
||||
The MCP surface deliberately wraps the existing Studio services instead of
|
||||
duplicating training or export logic. It is opt-in because several tools can
|
||||
start GPU work or write model artifacts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
|
||||
class BearerTokenMiddleware:
|
||||
"""Require an exact bearer token when Studio MCP is exposed remotely."""
|
||||
|
||||
def __init__(self, app: Any, token: str) -> None:
|
||||
if not token or not token.strip():
|
||||
raise ValueError("Studio MCP bearer token must be a non-empty value")
|
||||
if not token.isascii():
|
||||
# A non-ASCII token cannot be sent in an HTTP header; reject it here.
|
||||
raise ValueError("Studio MCP bearer token must contain ASCII characters only")
|
||||
self.app = app
|
||||
# Compare on raw header bytes: str hmac.compare_digest raises on non-ASCII
|
||||
# input, which would surface as a 500 instead of a clean 401.
|
||||
self.expected = token.encode("utf-8")
|
||||
|
||||
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
||||
scope_type = scope.get("type")
|
||||
if scope_type not in ("http", "websocket"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
headers = dict(scope.get("headers", []))
|
||||
raw_auth = headers.get(b"authorization", b"")
|
||||
scheme, _, supplied = raw_auth.partition(b" ")
|
||||
if scheme.lower() != b"bearer" or not hmac.compare_digest(supplied, self.expected):
|
||||
await _send_unauthorized(send, scope_type)
|
||||
return
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
async def _send_unauthorized(send: Any, scope_type: str) -> None:
|
||||
if scope_type == "websocket":
|
||||
await send({"type": "websocket.close", "code": 4401})
|
||||
return
|
||||
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [(b"content-type", b"application/json"), (b"www-authenticate", b"Bearer")],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": b'{"detail":"MCP bearer token required"}',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _dump(value: Any) -> Any:
|
||||
"""Convert Pydantic responses to plain JSON values for MCP clients."""
|
||||
if hasattr(value, "model_dump"):
|
||||
return value.model_dump(mode = "json")
|
||||
return value
|
||||
|
||||
|
||||
def _clamp(value: int, low: int, high: int) -> int:
|
||||
"""Clamp an MCP-supplied integer into an inclusive range.
|
||||
|
||||
MCP tools call the Studio route functions directly, which skips FastAPI's
|
||||
Query(ge=, le=) validation, so we re-apply the same bounds here.
|
||||
"""
|
||||
return max(low, min(value, high))
|
||||
|
||||
|
||||
def create_studio_mcp() -> FastMCP:
|
||||
"""Create the Studio MCP server and register the high-value tools."""
|
||||
mcp = FastMCP(
|
||||
"Unsloth Studio",
|
||||
instructions = (
|
||||
"Use read tools to inspect the local Studio state before starting GPU work. "
|
||||
"Training and export tools can consume substantial VRAM and write files. "
|
||||
"Never expose tokens or local paths from tool results unless the user asks."
|
||||
),
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
async def studio_status() -> dict[str, Any]:
|
||||
"""Return the current training, export, inference, and GPU state."""
|
||||
from routes.export import get_export_status
|
||||
from routes.inference import get_status as get_inference_status
|
||||
from routes.training import get_training_status
|
||||
|
||||
from utils.hardware import get_gpu_utilization
|
||||
|
||||
training, export, inference = await _gather_status(
|
||||
get_training_status(current_subject = "mcp"),
|
||||
get_export_status(current_subject = "mcp"),
|
||||
get_inference_status(current_subject = "mcp"),
|
||||
)
|
||||
return {
|
||||
"training": _dump(training),
|
||||
"export": _dump(export),
|
||||
"inference": _dump(inference),
|
||||
"hardware": get_gpu_utilization(),
|
||||
}
|
||||
|
||||
@mcp.tool
|
||||
async def list_local_models(models_dir: str = "./models") -> dict[str, Any]:
|
||||
"""List local and cached models available to Studio."""
|
||||
from routes.models import list_local_models as list_models
|
||||
return _dump(await list_models(models_dir = models_dir, current_subject = "mcp"))
|
||||
|
||||
@mcp.tool
|
||||
async def get_training_status() -> dict[str, Any]:
|
||||
"""Read the active training job, phase, progress, and recent metrics."""
|
||||
from routes.training import get_training_status as get_status
|
||||
return _dump(await get_status(current_subject = "mcp"))
|
||||
|
||||
@mcp.tool
|
||||
async def start_training(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Start a validated Studio training job from a TrainingStartRequest-shaped object.
|
||||
|
||||
The config is validated by the same Pydantic model used by the Studio UI.
|
||||
Call get_training_status first and do not start work while another job runs.
|
||||
"""
|
||||
from models import TrainingStartRequest
|
||||
from routes.training import start_training as start
|
||||
|
||||
request = TrainingStartRequest.model_validate(config)
|
||||
# Pass via_api_key explicitly (a direct call leaves it a Depends object).
|
||||
# MCP drives Studio like the UI session, so it coexists and frees VRAM.
|
||||
return _dump(await start(request, current_subject = "mcp", via_api_key = False))
|
||||
|
||||
@mcp.tool
|
||||
async def stop_training(save: bool = True) -> dict[str, Any]:
|
||||
"""Ask the active training process to stop at its next safe checkpoint."""
|
||||
from routes.training import TrainingStopRequest, stop_training as stop
|
||||
return _dump(await stop(TrainingStopRequest(save = save), current_subject = "mcp"))
|
||||
|
||||
@mcp.tool
|
||||
async def list_training_runs(limit: int = 50, offset: int = 0) -> dict[str, Any]:
|
||||
"""List completed and stopped training runs, newest first."""
|
||||
from routes.training_history import list_training_runs as list_runs
|
||||
|
||||
# Clamp here (direct call skips Query bounds); a negative LIMIT = no limit.
|
||||
limit = _clamp(limit, 1, 200)
|
||||
offset = max(0, offset)
|
||||
return _dump(await list_runs(limit = limit, offset = offset, current_subject = "mcp"))
|
||||
|
||||
@mcp.tool
|
||||
def validate_recipe(recipe: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate a Data Recipe with the same validator used by Studio."""
|
||||
from models.data_recipe import RecipePayload
|
||||
from routes.data_recipe.validate import validate
|
||||
|
||||
return _dump(validate(RecipePayload(recipe = recipe)))
|
||||
|
||||
@mcp.tool
|
||||
def get_recipe_job_status(job_id: str) -> dict[str, Any]:
|
||||
"""Read the status of a Data Recipe job."""
|
||||
from routes.data_recipe.jobs import job_status
|
||||
return _dump(job_status(job_id))
|
||||
|
||||
@mcp.tool
|
||||
def get_recipe_job_dataset(
|
||||
job_id: str,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Read a bounded page of generated Data Recipe rows."""
|
||||
from routes.data_recipe.jobs import job_dataset
|
||||
|
||||
# Clamp here (direct call skips FastAPI's Query bounds).
|
||||
limit = _clamp(limit, 1, 500)
|
||||
offset = max(0, offset)
|
||||
return _dump(job_dataset(job_id, limit = limit, offset = offset))
|
||||
|
||||
@mcp.tool
|
||||
async def load_checkpoint(
|
||||
checkpoint_path: str,
|
||||
max_seq_length: int = 2048,
|
||||
load_in_4bit: bool = True,
|
||||
trust_remote_code: bool = False,
|
||||
approved_remote_code_fingerprint: str | None = None,
|
||||
hf_token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load a checkpoint into the export backend.
|
||||
|
||||
Export runs in its own subprocess and coexists with training and
|
||||
inference; it does not unload them, so a load can fail with a clear
|
||||
out-of-memory error if the GPU is already full. Pass hf_token to load a
|
||||
gated checkpoint, and approved_remote_code_fingerprint to retry a
|
||||
trust_remote_code load that was blocked pending review.
|
||||
"""
|
||||
from models import LoadCheckpointRequest
|
||||
from routes.export import load_checkpoint as load
|
||||
|
||||
request = LoadCheckpointRequest(
|
||||
checkpoint_path = checkpoint_path,
|
||||
max_seq_length = max_seq_length,
|
||||
load_in_4bit = load_in_4bit,
|
||||
trust_remote_code = trust_remote_code,
|
||||
approved_remote_code_fingerprint = approved_remote_code_fingerprint,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
return _dump(await load(request, current_subject = "mcp"))
|
||||
|
||||
@mcp.tool
|
||||
async def export_gguf(
|
||||
save_directory: str,
|
||||
quantization_method: str | list[str] = "Q4_K_M",
|
||||
push_to_hub: bool = False,
|
||||
repo_id: str | None = None,
|
||||
hf_token: str | None = None,
|
||||
imatrix: bool = False,
|
||||
imatrix_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Export the loaded model to GGUF using Studio's existing path validation.
|
||||
|
||||
quantization_method may be a single method or a list to produce several
|
||||
GGUFs from one load. Pass hf_token when push_to_hub is set (the backend
|
||||
rejects a Hub upload without it). Set imatrix (or imatrix_path) for the
|
||||
IQ low-bit quants that require an importance matrix.
|
||||
"""
|
||||
from models import ExportGGUFRequest
|
||||
from routes.export import export_gguf as export
|
||||
|
||||
request = ExportGGUFRequest(
|
||||
save_directory = save_directory,
|
||||
quantization_method = quantization_method,
|
||||
push_to_hub = push_to_hub,
|
||||
repo_id = repo_id,
|
||||
hf_token = hf_token,
|
||||
imatrix = imatrix,
|
||||
imatrix_path = imatrix_path,
|
||||
)
|
||||
return _dump(await export(request, current_subject = "mcp"))
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
async def _gather_status(*coroutines: Any) -> tuple[Any, ...]:
|
||||
"""Gather independent status calls without letting one optional backend fail all state."""
|
||||
import asyncio
|
||||
|
||||
results = await asyncio.gather(*coroutines, return_exceptions = True)
|
||||
return tuple(
|
||||
{"error": str(result)} if isinstance(result, Exception) else result for result in results
|
||||
)
|
||||
|
|
@ -20,6 +20,7 @@ from loggers import get_logger
|
|||
import asyncio
|
||||
import threading
|
||||
import weakref
|
||||
from contextlib import ExitStack
|
||||
|
||||
|
||||
import re as _re
|
||||
|
|
@ -28,6 +29,7 @@ import re as _re
|
|||
from utils.models import extract_model_size_b as _extract_model_size_b
|
||||
|
||||
from utils.api_errors import openai_error_body, anthropic_error_body
|
||||
from core.inference.orchestrator import GenStreamError, GenStreamErrorRaised
|
||||
from core.inference.llama_admission import (
|
||||
LlamaAdmissionCancelled,
|
||||
LlamaAdmissionConfig,
|
||||
|
|
@ -212,6 +214,14 @@ def _friendly_error(exc: Exception) -> str:
|
|||
return "An internal error occurred"
|
||||
|
||||
|
||||
def _friendly_gen_stream_error(value) -> str:
|
||||
"""Return a client-safe message for typed local generation errors."""
|
||||
text = str(value)
|
||||
if getattr(value, "public", False):
|
||||
return text
|
||||
return safe_error_detail(RuntimeError(text), fallback = "An internal error occurred.")
|
||||
|
||||
|
||||
def _friendly_upstream_error(text: str) -> str:
|
||||
"""Rewrite a raw llama-server error body into an actionable message where we can.
|
||||
|
||||
|
|
@ -998,6 +1008,7 @@ try:
|
|||
from core.inference.llama_server_args import (
|
||||
_effective_tensor_parallel,
|
||||
_tensor_parallel_matches_loaded,
|
||||
extra_args_disable_mmproj,
|
||||
parse_split_mode_override,
|
||||
resolve_tensor_parallel,
|
||||
strip_shadowing_flags,
|
||||
|
|
@ -1035,6 +1046,7 @@ except ImportError:
|
|||
from core.inference.llama_server_args import (
|
||||
_effective_tensor_parallel,
|
||||
_tensor_parallel_matches_loaded,
|
||||
extra_args_disable_mmproj,
|
||||
parse_split_mode_override,
|
||||
resolve_tensor_parallel,
|
||||
strip_shadowing_flags,
|
||||
|
|
@ -1915,16 +1927,57 @@ async def artifact_preview_frame(allow_network: bool = False):
|
|||
_BARE_JSON_NAME_MARKER_RE = _re.compile(r'\{\s*\\?"(?:name|function)\\?"\s*:')
|
||||
|
||||
|
||||
def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
|
||||
def _detect_safetensors_features(
|
||||
backend,
|
||||
chat_template: Optional[str],
|
||||
tools = None,
|
||||
) -> dict:
|
||||
"""Classify reasoning/tool capabilities via the GGUF classifier so flags
|
||||
match across backends. gpt-oss is overridden: Harmony routes reasoning and
|
||||
tools through tokenizer channels, not template markup."""
|
||||
model_id = getattr(backend, "active_model_name", None)
|
||||
feature_template = chat_template
|
||||
try:
|
||||
from core.inference.chat_template_helpers import _selected_template_strings_from_value
|
||||
selected_templates = _selected_template_strings_from_value(chat_template, tools)
|
||||
if selected_templates:
|
||||
feature_template = selected_templates[0]
|
||||
except Exception:
|
||||
logger.debug("safetensors_named_template_selection_failed", exc_info = True)
|
||||
flags = detect_reasoning_flags(
|
||||
chat_template,
|
||||
feature_template,
|
||||
model_identifier = model_id,
|
||||
log_source = "safetensors",
|
||||
)
|
||||
if not flags.get("supports_reasoning"):
|
||||
try:
|
||||
from core.inference.chat_template_helpers import (
|
||||
detect_reasoning_channel_markers_from_template,
|
||||
)
|
||||
|
||||
templates = [chat_template]
|
||||
models = getattr(backend, "models", None)
|
||||
model_info = (
|
||||
models.get(model_id, {})
|
||||
if isinstance(models, dict) and model_id is not None
|
||||
else {}
|
||||
)
|
||||
if isinstance(model_info, dict):
|
||||
templates.extend(
|
||||
(
|
||||
model_info.get("native_chat_template"),
|
||||
(model_info.get("chat_template_info") or {}).get("template"),
|
||||
)
|
||||
)
|
||||
if any(
|
||||
detect_reasoning_channel_markers_from_template(template, tools = tools) is not None
|
||||
for template in templates
|
||||
):
|
||||
flags["supports_reasoning"] = True
|
||||
flags["reasoning_always_on"] = True
|
||||
logger.info("safetensors: model always reasons (native channel markers)")
|
||||
except Exception:
|
||||
logger.debug("safetensors_native_reasoning_marker_check_failed", exc_info = True)
|
||||
# Markers any supported parser recognises (template advertises tools but
|
||||
# uses none -> drop the pill). Reuse the parser's own signal list so this
|
||||
# gate never drifts (a hand-maintained copy lost the DeepSeek variants);
|
||||
|
|
@ -1938,9 +1991,9 @@ def _detect_safetensors_features(backend, chat_template: Optional[str]) -> dict:
|
|||
)
|
||||
if (
|
||||
flags.get("supports_tools")
|
||||
and chat_template
|
||||
and not any(m in chat_template for m in _PARSER_MARKERS)
|
||||
and not _BARE_JSON_NAME_MARKER_RE.search(chat_template)
|
||||
and isinstance(feature_template, str)
|
||||
and not any(m in feature_template for m in _PARSER_MARKERS)
|
||||
and not _BARE_JSON_NAME_MARKER_RE.search(feature_template)
|
||||
):
|
||||
logger.info(
|
||||
"safetensors: template advertises tools but uses an "
|
||||
|
|
@ -3968,6 +4021,7 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
|
|||
|
||||
native_grant_backed = False
|
||||
model_log_label = request.model_path
|
||||
gguf_load_stack = ExitStack()
|
||||
try:
|
||||
# Validate user pass-through args up front so a managed-flag collision
|
||||
# returns 400 before any model work.
|
||||
|
|
@ -4187,15 +4241,9 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
|
|||
llama_backend = get_llama_cpp_backend()
|
||||
unsloth_backend = get_inference_backend()
|
||||
|
||||
# Unload any active Unsloth model to free VRAM (off the event loop:
|
||||
# unload takes _gen_lock and can wait on an in-flight stream).
|
||||
if unsloth_backend.active_model_name:
|
||||
logger.info(
|
||||
f"Unloading Unsloth model '{unsloth_backend.active_model_name}' before loading GGUF"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
unsloth_backend.unload_model, unsloth_backend.active_model_name
|
||||
)
|
||||
if config.gguf_hf_repo:
|
||||
from core.inference.llama_cpp import gguf_load_in_flight
|
||||
gguf_load_stack.enter_context(gguf_load_in_flight(config.gguf_hf_repo))
|
||||
|
||||
# Inherit llama_extra_args from the previous load when the request
|
||||
# omits the field (the chat-settings Apply path doesn't round-trip
|
||||
|
|
@ -4275,6 +4323,38 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
|
|||
extra_llama_args,
|
||||
)
|
||||
|
||||
# Block cache writes that would race the download manager. This runs
|
||||
# after pass-through argument inheritance so a carried --no-mmproj
|
||||
# changes the companion requirement exactly as it does for the load.
|
||||
if config.gguf_hf_repo:
|
||||
from core.inference.llama_cpp import _hub_download_blocks_gguf_load
|
||||
if await asyncio.to_thread(
|
||||
_hub_download_blocks_gguf_load,
|
||||
config.gguf_hf_repo,
|
||||
config.gguf_variant,
|
||||
require_mmproj = bool(
|
||||
config.is_vision and not extra_args_disable_mmproj(extra_llama_args)
|
||||
),
|
||||
hf_token = request.hf_token,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code = 409,
|
||||
detail = (
|
||||
f"'{model_log_label}' is currently being downloaded "
|
||||
"by the download manager. Wait for the download to "
|
||||
"finish (or cancel it), then load the model."
|
||||
),
|
||||
)
|
||||
|
||||
# Unload any active Unsloth model only after every hub conflict check.
|
||||
if unsloth_backend.active_model_name:
|
||||
logger.info(
|
||||
f"Unloading Unsloth model '{unsloth_backend.active_model_name}' before loading GGUF"
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
unsloth_backend.unload_model, unsloth_backend.active_model_name
|
||||
)
|
||||
|
||||
# Route to HF or local mode based on config. Run in a thread so the
|
||||
# event loop stays free for progress polling and other requests
|
||||
# during the (potentially long) GGUF download + llama-server start.
|
||||
|
|
@ -4639,6 +4719,8 @@ async def _load_model_impl(request: LoadRequest, fastapi_request: Request, curre
|
|||
logger.error(f"Error loading model: {e}", exc_info = True)
|
||||
msg = _maybe_unsupported_message(redacted_msg)
|
||||
raise HTTPException(status_code = 500, detail = f"Failed to load model: {msg}")
|
||||
finally:
|
||||
gguf_load_stack.close()
|
||||
|
||||
|
||||
def _requires_trust_remote_code_for_model(
|
||||
|
|
@ -5392,6 +5474,10 @@ async def generate_stream(
|
|||
if chunk is _DONE:
|
||||
completed = True
|
||||
break
|
||||
if isinstance(chunk, GenStreamError):
|
||||
yield f"data: {json.dumps({'error': _friendly_gen_stream_error(chunk)})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
yield f"data: {json.dumps({'content': chunk})}\n\n"
|
||||
if completed:
|
||||
yield "data: [DONE]\n\n"
|
||||
|
|
@ -5405,6 +5491,7 @@ async def generate_stream(
|
|||
backend.reset_generation_state()
|
||||
logger.error(f"Error during generation: {e}", exc_info = True)
|
||||
yield f"data: {json.dumps({'error': _friendly_error(e)})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
if not completed and not cancel_event.is_set():
|
||||
|
|
@ -7028,6 +7115,13 @@ async def openai_chat_completions(
|
|||
chunk_text = await asyncio.to_thread(next, gen, _DONE)
|
||||
if chunk_text is _DONE:
|
||||
break
|
||||
if isinstance(chunk_text, GenStreamError):
|
||||
_msg = _friendly_gen_stream_error(chunk_text)
|
||||
api_monitor.fail(monitor_id, _msg)
|
||||
yield _openai_stream_error_sse(
|
||||
{"error": {"message": _msg, "type": "server_error"}}
|
||||
)
|
||||
return
|
||||
if chunk_text:
|
||||
api_monitor.append_reply(monitor_id, chunk_text)
|
||||
yield _chat_content_chunk(
|
||||
|
|
@ -7043,8 +7137,11 @@ async def openai_chat_completions(
|
|||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error during audio input streaming: {e}", exc_info = True)
|
||||
api_monitor.fail(monitor_id, _friendly_error(e))
|
||||
yield f"data: {json.dumps({'error': {'message': _friendly_error(e), 'type': 'server_error'}})}\n\n"
|
||||
_msg = _friendly_error(e)
|
||||
api_monitor.fail(monitor_id, _msg)
|
||||
yield _openai_stream_error_sse(
|
||||
{"error": {"message": _msg, "type": "server_error"}}
|
||||
)
|
||||
finally:
|
||||
await _stop_local_disconnect_cancel_watcher(disconnect_watcher)
|
||||
_tracker.__exit__(None, None, None)
|
||||
|
|
@ -7061,7 +7158,15 @@ async def openai_chat_completions(
|
|||
)
|
||||
else:
|
||||
try:
|
||||
full_text = "".join(audio_input_generate())
|
||||
full_text = ""
|
||||
for chunk_text in audio_input_generate():
|
||||
if isinstance(chunk_text, GenStreamError):
|
||||
_msg = _friendly_gen_stream_error(chunk_text)
|
||||
api_monitor.fail(monitor_id, _msg)
|
||||
raise HTTPException(status_code = 500, detail = _msg)
|
||||
full_text += chunk_text
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
api_monitor.fail(monitor_id, _friendly_error(e))
|
||||
raise
|
||||
|
|
@ -8605,19 +8710,33 @@ async def openai_chat_completions(
|
|||
# Classify capability flags from the loaded template.
|
||||
_sf_model_info = backend.models.get(backend.active_model_name, {})
|
||||
_sf_tpl = (_sf_model_info.get("chat_template_info") or {}).get("template")
|
||||
_sf_features = _detect_safetensors_features(backend, _sf_tpl)
|
||||
|
||||
# GGUF parity: enable_thinking templates prefill an unclosed <think>; split into
|
||||
# reasoning_content deltas so the UI renders the block for safetensors and MLX.
|
||||
_sf_parse_think = bool(
|
||||
_sf_features.get("supports_reasoning") or _sf_features.get("reasoning_always_on")
|
||||
# Named templates may expose native reasoning only in their ``tool_use``
|
||||
# branch. Use a truthy placeholder for Studio-managed tools, whose concrete
|
||||
# schemas are selected below, and the request schemas for client passthrough.
|
||||
_sf_server_tool_intent = bool(
|
||||
_effective_enable_tools(payload) or _explicit_studio_tool_loop_requested(payload)
|
||||
)
|
||||
# Prefilled-open only for prefill styles with thinking on; gpt-oss uses the normal mode.
|
||||
_sf_reasoning_prefilled = _sf_reasoning_prefill_mode(
|
||||
_sf_features,
|
||||
payload.enable_thinking,
|
||||
_sf_tpl,
|
||||
reasoning_effort = payload.reasoning_effort,
|
||||
_sf_template_tools = payload.tools if payload.tool_choice != "none" else None
|
||||
if not _sf_template_tools and _sf_server_tool_intent:
|
||||
_sf_template_tools = ({},)
|
||||
|
||||
def _sf_response_protocol(tools = None):
|
||||
features = _detect_safetensors_features(backend, _sf_tpl, tools = tools)
|
||||
parse_think = bool(
|
||||
features.get("supports_reasoning") or features.get("reasoning_always_on")
|
||||
)
|
||||
reasoning_prefilled = _sf_reasoning_prefill_mode(
|
||||
features,
|
||||
payload.enable_thinking,
|
||||
_sf_tpl,
|
||||
reasoning_effort = payload.reasoning_effort,
|
||||
)
|
||||
return features, parse_think, reasoning_prefilled
|
||||
|
||||
# GGUF parity: split canonical <think> output into reasoning_content. The
|
||||
# selected template branch must match whether this request renders tools.
|
||||
_sf_features, _sf_parse_think, _sf_reasoning_prefilled = _sf_response_protocol(
|
||||
_sf_template_tools
|
||||
)
|
||||
|
||||
def _new_sf_reasoning_extractor():
|
||||
|
|
@ -8767,6 +8886,7 @@ async def openai_chat_completions(
|
|||
permission_mode = payload.permission_mode,
|
||||
use_adapter = payload.use_adapter,
|
||||
stats_holder = _sf_stats_holder,
|
||||
reasoning_prefilled = _sf_reasoning_prefilled,
|
||||
)
|
||||
|
||||
_sf_tool_sentinel = object()
|
||||
|
|
@ -8826,6 +8946,18 @@ async def openai_chat_completions(
|
|||
_sf_next_task = None
|
||||
if event is _sf_tool_sentinel:
|
||||
break
|
||||
if isinstance(event, GenStreamError):
|
||||
backend.reset_generation_state()
|
||||
_msg = _friendly_gen_stream_error(event)
|
||||
api_monitor.fail(monitor_id, _msg)
|
||||
yield _openai_stream_error_sse(
|
||||
{"error": {"message": _msg, "type": "server_error"}}
|
||||
)
|
||||
return
|
||||
if not isinstance(event, dict):
|
||||
raise RuntimeError(
|
||||
f"Invalid safetensors tool event: {type(event).__name__}"
|
||||
)
|
||||
|
||||
if event["type"] == "heartbeat":
|
||||
# Tool-execution wrapper heartbeat -> SSE keepalive.
|
||||
|
|
@ -8913,6 +9045,11 @@ async def openai_chat_completions(
|
|||
backend.reset_generation_state()
|
||||
api_monitor.finish(monitor_id, "cancelled")
|
||||
raise
|
||||
except GenStreamErrorRaised as exc:
|
||||
backend.reset_generation_state()
|
||||
_msg = _friendly_gen_stream_error(exc)
|
||||
api_monitor.fail(monitor_id, _msg)
|
||||
yield _openai_stream_error_sse({"error": {"message": _msg, "type": "server_error"}})
|
||||
except Exception:
|
||||
backend.reset_generation_state()
|
||||
# Generic wire message; full trace stays in the log (CWE-209:
|
||||
|
|
@ -8962,6 +9099,15 @@ async def openai_chat_completions(
|
|||
for event in gen:
|
||||
if cancel_event.is_set():
|
||||
break
|
||||
if isinstance(event, GenStreamError):
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = _friendly_gen_stream_error(event),
|
||||
)
|
||||
if not isinstance(event, dict):
|
||||
raise RuntimeError(
|
||||
f"Invalid safetensors tool event: {type(event).__name__}"
|
||||
)
|
||||
if event.get("type") == "content":
|
||||
full_text = _strip_tool_xml_for_display(
|
||||
event.get("text", ""),
|
||||
|
|
@ -9002,6 +9148,15 @@ async def openai_chat_completions(
|
|||
backend.reset_generation_state()
|
||||
api_monitor.finish(monitor_id, "cancelled")
|
||||
raise
|
||||
except GenStreamErrorRaised as exc:
|
||||
backend.reset_generation_state()
|
||||
_msg = _friendly_gen_stream_error(exc)
|
||||
api_monitor.fail(monitor_id, _msg)
|
||||
raise HTTPException(status_code = 500, detail = _msg)
|
||||
except HTTPException as exc:
|
||||
backend.reset_generation_state()
|
||||
api_monitor.fail(monitor_id, str(exc.detail))
|
||||
raise
|
||||
except Exception:
|
||||
backend.reset_generation_state()
|
||||
# CWE-209: generic detail; full trace in log.
|
||||
|
|
@ -9088,6 +9243,12 @@ async def openai_chat_completions(
|
|||
else:
|
||||
gen_kwargs["tools"] = payload.tools
|
||||
|
||||
# The potential tool context above is needed before server/client routing is
|
||||
# known. This standard path now has the exact schemas that will be rendered,
|
||||
# so resolve reasoning parsing again to keep empty registries, forced-tool
|
||||
# misses, and tool_choice="none" on the marker-free template branch.
|
||||
_, _sf_parse_think, _sf_reasoning_prefilled = _sf_response_protocol(gen_kwargs.get("tools"))
|
||||
|
||||
# Request-scoped usage/timings receptacle (filled at gen_done).
|
||||
stats_holder: dict = {}
|
||||
|
||||
|
|
@ -9168,6 +9329,14 @@ async def openai_chat_completions(
|
|||
_next_task = None
|
||||
if cumulative is _DONE:
|
||||
break
|
||||
if isinstance(cumulative, GenStreamError):
|
||||
backend.reset_generation_state()
|
||||
_msg = _friendly_gen_stream_error(cumulative)
|
||||
api_monitor.fail(monitor_id, _msg)
|
||||
yield _openai_stream_error_sse(
|
||||
{"error": {"message": _msg, "type": "server_error"}}
|
||||
)
|
||||
return
|
||||
if await request.is_disconnected():
|
||||
cancel_event.set()
|
||||
backend.reset_generation_state()
|
||||
|
|
@ -9317,6 +9486,11 @@ async def openai_chat_completions(
|
|||
try:
|
||||
full_text = ""
|
||||
for token in generate():
|
||||
if isinstance(token, GenStreamError):
|
||||
backend.reset_generation_state()
|
||||
_msg = _friendly_gen_stream_error(token)
|
||||
api_monitor.fail(monitor_id, _msg)
|
||||
raise HTTPException(status_code = 500, detail = _msg)
|
||||
full_text = token
|
||||
|
||||
# Split prefilled <think> reasoning (GGUF parity); also covers MLX via
|
||||
|
|
@ -9415,6 +9589,8 @@ async def openai_chat_completions(
|
|||
api_monitor.finish(monitor_id)
|
||||
return _model_json_response(response)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
backend.reset_generation_state()
|
||||
logger.error(f"Error during OpenAI completion: {e}", exc_info = True)
|
||||
|
|
|
|||
|
|
@ -1354,11 +1354,23 @@ def run_server(
|
|||
if secure:
|
||||
os.environ["UNSLOTH_SECURE"] = "1"
|
||||
|
||||
import nest_asyncio
|
||||
|
||||
nest_asyncio.apply()
|
||||
|
||||
import asyncio
|
||||
|
||||
# nest_asyncio is for Colab/IPython, where the main thread already runs a loop
|
||||
# the blocking waits below would collide with. Apply it only with a loop running
|
||||
# (a plain CLI start has nothing to nest) and only on Python <= 3.13: on 3.14+
|
||||
# its global Task patch leaves asyncio.current_task() None (tracking moved into
|
||||
# C), which also breaks the background uvicorn loop and 500s every request. It
|
||||
# is archived upstream, so no 3.14 fix is coming; skip it there.
|
||||
if sys.version_info < (3, 14):
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
from threading import Thread, Event
|
||||
import uvicorn
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ from __future__ import annotations
|
|||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -128,15 +127,13 @@ def _kwargs_for(flags: dict, enable_thinking, reasoning_effort):
|
|||
"""Drive the real backend method with a shim carrying the detected flags."""
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
|
||||
shim = SimpleNamespace(
|
||||
_supports_reasoning = flags["supports_reasoning"],
|
||||
_reasoning_always_on = flags["reasoning_always_on"],
|
||||
_reasoning_style = flags["reasoning_style"],
|
||||
_reasoning_effort_levels = flags["reasoning_effort_levels"],
|
||||
_supports_preserve_thinking = flags["supports_preserve_thinking"],
|
||||
)
|
||||
build = LlamaCppBackend._request_reasoning_kwargs.__get__(shim)
|
||||
return build(enable_thinking, reasoning_effort, None) or {}
|
||||
shim = object.__new__(LlamaCppBackend)
|
||||
shim._supports_reasoning = flags["supports_reasoning"]
|
||||
shim._reasoning_always_on = flags["reasoning_always_on"]
|
||||
shim._reasoning_style = flags["reasoning_style"]
|
||||
shim._reasoning_effort_levels = flags["reasoning_effort_levels"]
|
||||
shim._supports_preserve_thinking = flags["supports_preserve_thinking"]
|
||||
return shim._request_reasoning_kwargs(enable_thinking, reasoning_effort, None) or {}
|
||||
|
||||
|
||||
def _flags():
|
||||
|
|
|
|||
740
studio/backend/tests/test_gguf_load_cache_reuse.py
Normal file
740
studio/backend/tests/test_gguf_load_cache_reuse.py
Normal file
|
|
@ -0,0 +1,740 @@
|
|||
# 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 cached GGUF reuse and load/download exclusion.
|
||||
|
||||
No GPU, network, or subprocesses are required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import types as _types
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, _BACKEND_DIR)
|
||||
|
||||
# Stub optional dependencies before importing the modules under test.
|
||||
_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)
|
||||
|
||||
try:
|
||||
import httpx # noqa: F401
|
||||
except ImportError:
|
||||
_httpx_stub = _types.ModuleType("httpx")
|
||||
for _exc_name in (
|
||||
"ConnectError",
|
||||
"TimeoutException",
|
||||
"ReadTimeout",
|
||||
"ReadError",
|
||||
"RemoteProtocolError",
|
||||
"CloseError",
|
||||
"HTTPError",
|
||||
"RequestError",
|
||||
"HTTPStatusError",
|
||||
):
|
||||
setattr(_httpx_stub, _exc_name, type(_exc_name, (Exception,), {}))
|
||||
_httpx_stub.Response = type("Response", (), {})
|
||||
_httpx_stub.Request = type("Request", (), {})
|
||||
|
||||
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 huggingface_hub import constants as hf_constants
|
||||
|
||||
from core.inference.llama_cpp import (
|
||||
LlamaCppBackend,
|
||||
cached_gguf_for_load,
|
||||
gguf_load_in_flight,
|
||||
hf_gguf_load_in_flight,
|
||||
)
|
||||
|
||||
|
||||
REPO = "unsloth/gemma-test-GGUF"
|
||||
VARIANT = "UD-Q4_K_XL"
|
||||
MAIN = f"gemma-test-{VARIANT}.gguf"
|
||||
|
||||
|
||||
def _build_cache(
|
||||
root: Path,
|
||||
repo_id: str,
|
||||
files: dict[str, int],
|
||||
*,
|
||||
snapshot_sha: str = "a" * 40,
|
||||
) -> Path:
|
||||
"""Create ``$root/models--<repo>/snapshots/<sha>/<rel>`` for each entry."""
|
||||
repo_dir = root / f"models--{repo_id.replace('/', '--')}"
|
||||
(repo_dir / "blobs").mkdir(parents = True, exist_ok = True)
|
||||
snap = repo_dir / "snapshots" / snapshot_sha
|
||||
snap.mkdir(parents = True, exist_ok = True)
|
||||
for rel, size in files.items():
|
||||
full = snap / rel
|
||||
full.parent.mkdir(parents = True, exist_ok = True)
|
||||
full.write_bytes(b"\0" * size)
|
||||
return snap
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hf_cache(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hf_constants, "HF_HUB_CACHE", str(tmp_path))
|
||||
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
|
||||
monkeypatch.delenv("TRANSFORMERS_OFFLINE", raising = False)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _fail_download(*_args, **_kwargs):
|
||||
raise AssertionError("must reuse the cached GGUF instead of downloading")
|
||||
|
||||
|
||||
def _fail_get_paths_info(*_args, **_kwargs):
|
||||
raise AssertionError("cached reuse must return before the sizing preflight")
|
||||
|
||||
|
||||
class TestLoadReusesCachedCopy:
|
||||
def test_online_reuse_after_revision_bump(self, hf_cache):
|
||||
"""A new repo revision does not replace a complete cached model."""
|
||||
backend = LlamaCppBackend()
|
||||
snap = _build_cache(hf_cache, REPO, {MAIN: 4})
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", _fail_get_paths_info),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", _fail_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert out == str(snap / MAIN)
|
||||
|
||||
def test_reuse_size_check_uses_cached_snapshot_revision(self, hf_cache):
|
||||
"""Current-revision size changes do not invalidate an older complete copy."""
|
||||
backend = LlamaCppBackend()
|
||||
snap = _build_cache(hf_cache, REPO, {MAIN: 4})
|
||||
revisions: list[str | None] = []
|
||||
|
||||
def fake_get_paths_info(
|
||||
_repo,
|
||||
paths,
|
||||
*,
|
||||
revision = None,
|
||||
token = None,
|
||||
):
|
||||
revisions.append(revision)
|
||||
size = 4 if revision == snap.name else 8
|
||||
return [_types.SimpleNamespace(path = path, size = size) for path in paths]
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", _fail_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert out == str(snap / MAIN)
|
||||
assert revisions == [snap.name]
|
||||
|
||||
def test_reuse_when_cached_revision_vanished_from_hub(self, hf_cache):
|
||||
"""The Hub answers an unknown revision with an empty result, not an error."""
|
||||
backend = LlamaCppBackend()
|
||||
snap = _build_cache(hf_cache, REPO, {MAIN: 4})
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", lambda *_a, **_k: []),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", _fail_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert out == str(snap / MAIN)
|
||||
|
||||
def test_truncated_cached_file_is_not_reused(self, hf_cache):
|
||||
backend = LlamaCppBackend()
|
||||
_build_cache(hf_cache, REPO, {MAIN: 4})
|
||||
downloaded: list[str] = []
|
||||
|
||||
def fake_get_paths_info(
|
||||
_repo,
|
||||
paths,
|
||||
*,
|
||||
revision = None,
|
||||
token = None,
|
||||
):
|
||||
return [_types.SimpleNamespace(path = path, size = 8) for path in paths]
|
||||
|
||||
def fake_download(
|
||||
repo_id,
|
||||
filename,
|
||||
token = None,
|
||||
**_kwargs,
|
||||
):
|
||||
downloaded.append(filename)
|
||||
return f"/fake/{repo_id}/{filename}"
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert downloaded == [MAIN]
|
||||
assert out == f"/fake/{REPO}/{MAIN}"
|
||||
|
||||
def test_truncated_cached_split_shard_is_not_reused(self, hf_cache):
|
||||
backend = LlamaCppBackend()
|
||||
shard1 = f"gemma-test-{VARIANT}-00001-of-00002.gguf"
|
||||
shard2 = f"gemma-test-{VARIANT}-00002-of-00002.gguf"
|
||||
_build_cache(hf_cache, REPO, {shard1: 8, shard2: 4})
|
||||
downloaded: list[str] = []
|
||||
|
||||
def fake_get_paths_info(
|
||||
_repo,
|
||||
paths,
|
||||
*,
|
||||
revision = None,
|
||||
token = None,
|
||||
):
|
||||
return [_types.SimpleNamespace(path = path, size = 8) for path in paths]
|
||||
|
||||
def fake_download(
|
||||
repo_id,
|
||||
filename,
|
||||
token = None,
|
||||
**_kwargs,
|
||||
):
|
||||
downloaded.append(filename)
|
||||
return f"/fake/{repo_id}/{filename}"
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [shard1, shard2]),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert downloaded == [shard1, shard2]
|
||||
assert out == f"/fake/{REPO}/{shard1}"
|
||||
|
||||
def test_online_reuse_when_reupload_renamed_the_file(self, hf_cache):
|
||||
"""A renamed variant still reuses its cached file."""
|
||||
backend = LlamaCppBackend()
|
||||
old_name = f"gemma-test-old-{VARIANT}.gguf"
|
||||
snap = _build_cache(hf_cache, REPO, {old_name: 4})
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", _fail_get_paths_info),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", _fail_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert out == str(snap / old_name)
|
||||
|
||||
def test_downloads_when_nothing_cached(self, hf_cache):
|
||||
backend = LlamaCppBackend()
|
||||
downloaded: list[str] = []
|
||||
|
||||
def fake_download(
|
||||
repo_id,
|
||||
filename,
|
||||
token = None,
|
||||
**_kwargs,
|
||||
):
|
||||
downloaded.append(filename)
|
||||
return f"/fake/{repo_id}/{filename}"
|
||||
|
||||
def fake_get_paths_info(
|
||||
_repo_id,
|
||||
paths,
|
||||
token = None,
|
||||
):
|
||||
return [_types.SimpleNamespace(path = p, size = 1) for p in paths if p is not None]
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert downloaded == [MAIN]
|
||||
assert out == f"/fake/{REPO}/{MAIN}"
|
||||
|
||||
def test_force_redownloads_despite_cache(self, hf_cache):
|
||||
"""A forced download ignores a complete cached copy."""
|
||||
backend = LlamaCppBackend()
|
||||
_build_cache(hf_cache, REPO, {MAIN: 4})
|
||||
downloaded: list[str] = []
|
||||
|
||||
def fake_download(
|
||||
repo_id,
|
||||
filename,
|
||||
token = None,
|
||||
**kwargs,
|
||||
):
|
||||
assert kwargs.get("force_download") is True
|
||||
downloaded.append(filename)
|
||||
return f"/fake/{repo_id}/{filename}"
|
||||
|
||||
def fake_get_paths_info(
|
||||
_repo_id,
|
||||
paths,
|
||||
token = None,
|
||||
):
|
||||
return [_types.SimpleNamespace(path = p, size = 1) for p in paths if p is not None]
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT, force = True)
|
||||
|
||||
assert downloaded == [MAIN]
|
||||
assert out == f"/fake/{REPO}/{MAIN}"
|
||||
|
||||
def test_split_reused_only_when_colocated(self, hf_cache):
|
||||
backend = LlamaCppBackend()
|
||||
shard1 = f"gemma-test-{VARIANT}-00001-of-00002.gguf"
|
||||
shard2 = f"gemma-test-{VARIANT}-00002-of-00002.gguf"
|
||||
snap = _build_cache(hf_cache, REPO, {shard1: 4, shard2: 4})
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [shard1, shard2]),
|
||||
patch("huggingface_hub.get_paths_info", _fail_get_paths_info),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", _fail_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert out == str(snap / shard1)
|
||||
|
||||
def test_partial_split_set_downloads(self, hf_cache):
|
||||
"""A partial split set is not reused."""
|
||||
backend = LlamaCppBackend()
|
||||
shard1 = f"gemma-test-{VARIANT}-00001-of-00002.gguf"
|
||||
shard2 = f"gemma-test-{VARIANT}-00002-of-00002.gguf"
|
||||
_build_cache(hf_cache, REPO, {shard1: 4})
|
||||
downloaded: list[str] = []
|
||||
|
||||
def fake_download(
|
||||
repo_id,
|
||||
filename,
|
||||
token = None,
|
||||
**_kwargs,
|
||||
):
|
||||
downloaded.append(filename)
|
||||
return f"/fake/{repo_id}/{filename}"
|
||||
|
||||
def fake_get_paths_info(
|
||||
_repo_id,
|
||||
paths,
|
||||
token = None,
|
||||
):
|
||||
return [_types.SimpleNamespace(path = p, size = 4) for p in paths if p is not None]
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [shard1, shard2]),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert downloaded == [shard1, shard2]
|
||||
assert out == f"/fake/{REPO}/{shard1}"
|
||||
|
||||
def test_reuse_prefers_newest_snapshot_after_update(self, hf_cache):
|
||||
"""Loads prefer the newest complete snapshot."""
|
||||
import os
|
||||
|
||||
backend = LlamaCppBackend()
|
||||
old_snap = _build_cache(hf_cache, REPO, {MAIN: 4}, snapshot_sha = "a" * 40)
|
||||
new_snap = _build_cache(hf_cache, REPO, {MAIN: 6}, snapshot_sha = "b" * 40)
|
||||
os.utime(old_snap, (1_000_000, 1_000_000))
|
||||
os.utime(new_snap, (2_000_000, 2_000_000))
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", _fail_get_paths_info),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", _fail_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert out == str(new_snap / MAIN)
|
||||
|
||||
def test_low_disk_fallback_reuses_cached_copy(self, hf_cache):
|
||||
backend = LlamaCppBackend()
|
||||
fallback = "gemma-test-Q2_K.gguf"
|
||||
snap = _build_cache(hf_cache, REPO, {fallback: 4})
|
||||
|
||||
def fake_get_paths_info(
|
||||
_repo,
|
||||
paths,
|
||||
*,
|
||||
revision = None,
|
||||
token = None,
|
||||
):
|
||||
size = 4 if revision == snap.name else 100
|
||||
return [_types.SimpleNamespace(path = path, size = size) for path in paths]
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", lambda *_a, **_k: [MAIN]),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
|
||||
patch("shutil.disk_usage", lambda *_a, **_k: _types.SimpleNamespace(free = 10)),
|
||||
patch.object(
|
||||
backend,
|
||||
"_find_smallest_fitting_variant",
|
||||
lambda *_a, **_k: (fallback, 4, []),
|
||||
),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", _fail_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = REPO, hf_variant = VARIANT)
|
||||
|
||||
assert out == str(snap / fallback)
|
||||
|
||||
def test_companion_prefers_main_snapshot_sibling(self, hf_cache):
|
||||
"""A cached mmproj is reused from the main model's snapshot."""
|
||||
backend = LlamaCppBackend()
|
||||
snap = _build_cache(hf_cache, REPO, {MAIN: 4, "mmproj-F16.gguf": 2})
|
||||
|
||||
def _fail_list(*_args, **_kwargs):
|
||||
raise AssertionError("snapshot sibling must resolve without a repo listing")
|
||||
|
||||
with patch("huggingface_hub.list_repo_files", _fail_list):
|
||||
out = backend._download_mmproj(hf_repo = REPO, near_path = str(snap / MAIN))
|
||||
|
||||
assert out == str(snap / "mmproj-F16.gguf")
|
||||
|
||||
def test_companion_finds_snapshot_through_hf_symlink(self, hf_cache):
|
||||
backend = LlamaCppBackend()
|
||||
snap = _build_cache(hf_cache, REPO, {})
|
||||
blobs = snap.parent.parent / "blobs"
|
||||
main_blob = blobs / "main"
|
||||
mmproj_blob = blobs / "mmproj"
|
||||
main_blob.write_bytes(b"main")
|
||||
mmproj_blob.write_bytes(b"mmproj")
|
||||
try:
|
||||
(snap / MAIN).symlink_to(main_blob)
|
||||
(snap / "mmproj-F16.gguf").symlink_to(mmproj_blob)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
with patch("huggingface_hub.list_repo_files", _fail_download):
|
||||
out = backend._download_mmproj(hf_repo = REPO, near_path = str(snap / MAIN))
|
||||
|
||||
assert out == str(snap / "mmproj-F16.gguf")
|
||||
|
||||
def test_companion_does_not_download_during_hub_job(self, hf_cache):
|
||||
backend = LlamaCppBackend()
|
||||
snap = _build_cache(hf_cache, REPO, {MAIN: 4})
|
||||
registry = _types.SimpleNamespace(active_job_refs = lambda _repo: [object()])
|
||||
|
||||
with (
|
||||
patch("huggingface_hub.list_repo_files", _fail_download),
|
||||
patch("hub.utils.download_registry.get_models_registry", lambda: registry),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", _fail_download),
|
||||
):
|
||||
out = backend._download_mmproj(hf_repo = REPO, near_path = str(snap / MAIN))
|
||||
|
||||
assert out is None
|
||||
|
||||
|
||||
class TestCachedGgufForLoadProbe:
|
||||
def test_complete_copy_found(self, hf_cache):
|
||||
snap = _build_cache(hf_cache, REPO, {MAIN: 4})
|
||||
assert cached_gguf_for_load(REPO, VARIANT) == str(snap / MAIN)
|
||||
|
||||
def test_absent_copy_is_none(self, hf_cache):
|
||||
assert cached_gguf_for_load(REPO, VARIANT) is None
|
||||
|
||||
def test_partial_split_is_none(self, hf_cache):
|
||||
shard1 = f"gemma-test-{VARIANT}-00001-of-00002.gguf"
|
||||
_build_cache(hf_cache, REPO, {shard1: 4})
|
||||
assert cached_gguf_for_load(REPO, VARIANT) is None
|
||||
|
||||
def test_partial_new_snapshot_does_not_hide_complete_split(self, hf_cache):
|
||||
import os
|
||||
|
||||
shard1 = f"gemma-test-{VARIANT}-00001-of-00002.gguf"
|
||||
shard2 = f"gemma-test-{VARIANT}-00002-of-00002.gguf"
|
||||
old = _build_cache(
|
||||
hf_cache,
|
||||
REPO,
|
||||
{shard1: 4, shard2: 4},
|
||||
snapshot_sha = "a" * 40,
|
||||
)
|
||||
new = _build_cache(hf_cache, REPO, {shard1: 4}, snapshot_sha = "b" * 40)
|
||||
os.utime(old, (1_000_000, 1_000_000))
|
||||
os.utime(new, (2_000_000, 2_000_000))
|
||||
|
||||
assert cached_gguf_for_load(REPO, VARIANT) == str(old / shard1)
|
||||
|
||||
def test_split_requires_every_declared_shard(self, hf_cache):
|
||||
shard1 = f"gemma-test-{VARIANT}-00001-of-00003.gguf"
|
||||
shard2 = f"gemma-test-{VARIANT}-00002-of-00003.gguf"
|
||||
_build_cache(hf_cache, REPO, {shard1: 4, shard2: 4})
|
||||
|
||||
assert cached_gguf_for_load(REPO, VARIANT) is None
|
||||
|
||||
def test_required_mmproj_must_share_main_snapshot(self, hf_cache):
|
||||
snap = _build_cache(hf_cache, REPO, {MAIN: 4})
|
||||
assert cached_gguf_for_load(REPO, VARIANT) == str(snap / MAIN)
|
||||
assert cached_gguf_for_load(REPO, VARIANT, require_mmproj = True) is None
|
||||
|
||||
(snap / "mmproj-F16.gguf").write_bytes(b"mmproj")
|
||||
assert cached_gguf_for_load(REPO, VARIANT, require_mmproj = True) == str(snap / MAIN)
|
||||
|
||||
def test_required_mmproj_scans_past_newer_main_only_snapshot(self, hf_cache):
|
||||
import os
|
||||
|
||||
old = _build_cache(
|
||||
hf_cache,
|
||||
REPO,
|
||||
{MAIN: 4, "mmproj-F16.gguf": 2},
|
||||
snapshot_sha = "a" * 40,
|
||||
)
|
||||
new = _build_cache(hf_cache, REPO, {MAIN: 4}, snapshot_sha = "b" * 40)
|
||||
os.utime(old, (1_000_000, 1_000_000))
|
||||
os.utime(new, (2_000_000, 2_000_000))
|
||||
|
||||
assert cached_gguf_for_load(REPO, VARIANT, require_mmproj = True) == str(old / MAIN)
|
||||
|
||||
|
||||
class TestLoadHubDownloadExclusion:
|
||||
def test_in_flight_marker_counts_and_normalizes_case(self):
|
||||
assert not hf_gguf_load_in_flight(REPO)
|
||||
with gguf_load_in_flight(REPO):
|
||||
assert hf_gguf_load_in_flight(REPO.upper())
|
||||
with gguf_load_in_flight(REPO.lower()):
|
||||
assert hf_gguf_load_in_flight(REPO)
|
||||
assert hf_gguf_load_in_flight(REPO)
|
||||
assert not hf_gguf_load_in_flight(REPO)
|
||||
|
||||
def test_marker_noops_for_local_loads(self):
|
||||
with gguf_load_in_flight(None):
|
||||
assert not hf_gguf_load_in_flight("")
|
||||
|
||||
def test_marker_cleared_on_exception(self):
|
||||
with pytest.raises(RuntimeError):
|
||||
with gguf_load_in_flight(REPO):
|
||||
raise RuntimeError("boom")
|
||||
assert not hf_gguf_load_in_flight(REPO)
|
||||
|
||||
def test_hub_download_refused_while_load_in_flight(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from hub.schemas.downloads import DownloadModelRequest
|
||||
from hub.services.models import downloads as dl
|
||||
|
||||
body = DownloadModelRequest(repo_id = REPO, gguf_variant = VARIANT)
|
||||
with (
|
||||
patch.object(dl, "resolve_cached_repo_id_case", lambda repo_id, repo_type: repo_id),
|
||||
gguf_load_in_flight(REPO),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(dl.download_model_response(body))
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "load" in exc_info.value.detail.lower()
|
||||
|
||||
def test_hub_download_rechecks_marker_before_claim(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from hub.schemas.downloads import DownloadModelRequest
|
||||
from hub.services.models import downloads as dl
|
||||
|
||||
scope = None
|
||||
|
||||
def mark_load(*_args, **_kwargs):
|
||||
nonlocal scope
|
||||
if scope is None:
|
||||
scope = gguf_load_in_flight(REPO)
|
||||
scope.__enter__()
|
||||
return frozenset()
|
||||
|
||||
class _Registry:
|
||||
def claim(self, *_args, admission_check, **_kwargs):
|
||||
assert admission_check() is False
|
||||
return False, "admission_blocked"
|
||||
|
||||
def current_generation(self, _key):
|
||||
return 0
|
||||
|
||||
registry = _Registry()
|
||||
body = DownloadModelRequest(repo_id = REPO, gguf_variant = VARIANT)
|
||||
try:
|
||||
with (
|
||||
patch.object(dl, "resolve_cached_repo_id_case", lambda repo_id, repo_type: repo_id),
|
||||
patch.object(dl.gguf_variants, "gguf_variant_blob_hashes", mark_load),
|
||||
patch.object(dl, "_registry", registry),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
asyncio.run(dl.download_model_response(body))
|
||||
finally:
|
||||
if scope is not None:
|
||||
scope.__exit__(None, None, None)
|
||||
|
||||
assert exc_info.value.status_code == 409
|
||||
|
||||
def test_registry_admission_check_prevents_claim(self):
|
||||
from hub.utils.download_registry import DownloadRegistry, TRANSPORT_HTTP
|
||||
|
||||
registry = DownloadRegistry()
|
||||
claimed, state = registry.claim(
|
||||
f"{REPO}::{VARIANT}",
|
||||
TRANSPORT_HTTP,
|
||||
repo_type = "model",
|
||||
repo_id = REPO,
|
||||
variant = VARIANT,
|
||||
admission_check = lambda: False,
|
||||
)
|
||||
|
||||
assert claimed is False
|
||||
assert state == "admission_blocked"
|
||||
assert registry.active_jobs(REPO) == {}
|
||||
|
||||
def test_same_variant_job_stays_visible_during_retry_handoff(self):
|
||||
from hub.utils.download_registry import DownloadRegistry, TRANSPORT_XET
|
||||
from core.inference.llama_cpp import _hub_download_blocks_gguf_load
|
||||
|
||||
registry = DownloadRegistry()
|
||||
key = f"{REPO}::{VARIANT}"
|
||||
claimed, _ = registry.claim(
|
||||
key,
|
||||
TRANSPORT_XET,
|
||||
repo_type = "model",
|
||||
repo_id = REPO,
|
||||
variant = VARIANT,
|
||||
)
|
||||
assert claimed is True
|
||||
assert registry.has_active_variant(REPO, VARIANT.lower()) is True
|
||||
|
||||
registry.release_active_slot(key)
|
||||
|
||||
assert registry.active_jobs(REPO) == {}
|
||||
assert registry.active_job_refs(REPO)
|
||||
assert registry.has_active_variant(REPO, VARIANT) is True
|
||||
with (
|
||||
patch("hub.utils.download_registry.get_models_registry", lambda: registry),
|
||||
patch(
|
||||
"core.inference.llama_cpp.cached_gguf_for_load",
|
||||
side_effect = AssertionError("same-variant jobs must block before cache reuse"),
|
||||
),
|
||||
):
|
||||
assert _hub_download_blocks_gguf_load(REPO, VARIANT) is True
|
||||
|
||||
registry.set_job(key, "complete")
|
||||
assert registry.has_active_variant(REPO, VARIANT) is False
|
||||
|
||||
def test_other_variant_job_still_allows_complete_cached_load(self):
|
||||
from core.inference.llama_cpp import _hub_download_blocks_gguf_load
|
||||
from hub.utils.download_registry import DownloadRegistry, TRANSPORT_HTTP
|
||||
|
||||
registry = DownloadRegistry()
|
||||
registry.claim(
|
||||
f"{REPO}::Q8_0",
|
||||
TRANSPORT_HTTP,
|
||||
repo_type = "model",
|
||||
repo_id = REPO,
|
||||
variant = "Q8_0",
|
||||
)
|
||||
with (
|
||||
patch("hub.utils.download_registry.get_models_registry", lambda: registry),
|
||||
patch(
|
||||
"core.inference.llama_cpp.cached_gguf_for_load",
|
||||
return_value = "/cached/model.gguf",
|
||||
) as cached_probe,
|
||||
):
|
||||
assert _hub_download_blocks_gguf_load(REPO, VARIANT) is False
|
||||
|
||||
cached_probe.assert_called_once_with(
|
||||
REPO,
|
||||
VARIANT,
|
||||
require_mmproj = False,
|
||||
verify_sizes = True,
|
||||
hf_token = None,
|
||||
)
|
||||
|
||||
def test_cancelled_request_keeps_marker_until_load_thread_finishes(self):
|
||||
from core.inference.llama_cpp import _with_gguf_load_marker
|
||||
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
finished = threading.Event()
|
||||
|
||||
class FakeBackend:
|
||||
@_with_gguf_load_marker
|
||||
def load_model(self, *, hf_repo):
|
||||
started.set()
|
||||
release.wait(timeout = 2)
|
||||
finished.set()
|
||||
return True
|
||||
|
||||
async def scenario():
|
||||
with patch(
|
||||
"core.inference.llama_cpp._hub_download_blocks_gguf_load",
|
||||
return_value = False,
|
||||
):
|
||||
task = asyncio.create_task(
|
||||
asyncio.to_thread(FakeBackend().load_model, hf_repo = REPO)
|
||||
)
|
||||
assert await asyncio.to_thread(started.wait, 1)
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert hf_gguf_load_in_flight(REPO)
|
||||
|
||||
release.set()
|
||||
assert await asyncio.to_thread(finished.wait, 1)
|
||||
for _ in range(100):
|
||||
if not hf_gguf_load_in_flight(REPO):
|
||||
break
|
||||
await asyncio.sleep(0.001)
|
||||
assert not hf_gguf_load_in_flight(REPO)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
def test_load_marker_precedes_hub_guard_and_unload(self):
|
||||
source = (Path(__file__).resolve().parent.parent / "routes" / "inference.py").read_text()
|
||||
gguf_branch = source[source.index("if config.is_gguf:") :]
|
||||
|
||||
assert (
|
||||
gguf_branch.index("enter_context(gguf_load_in_flight")
|
||||
< gguf_branch.index("if request.llama_extra_args is None")
|
||||
< gguf_branch.index("_hub_download_blocks_gguf_load")
|
||||
< gguf_branch.index("unsloth_backend.unload_model")
|
||||
)
|
||||
llama_source = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "inference" / "llama_cpp.py"
|
||||
).read_text()
|
||||
assert "@_with_gguf_load_marker\n def load_model(" in llama_source
|
||||
|
|
@ -1061,6 +1061,80 @@ def test_same_turn_duplicate_web_search_is_internal_noop(monkeypatch):
|
|||
]
|
||||
|
||||
|
||||
def test_same_turn_duplicate_does_not_drop_later_parallel_call(monkeypatch):
|
||||
# One batch: search(a), search(a) [duplicate], search(b). The duplicate is an
|
||||
# internal no-op, but the distinct search(b) after it must still run, and the
|
||||
# no-op nudge must land after the tool results rather than splitting them.
|
||||
batch = [
|
||||
_sse(
|
||||
{
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": "call_a1",
|
||||
"type": "function",
|
||||
"function": {"name": "web_search", "arguments": json.dumps({"query": "a"})},
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"id": "call_a2",
|
||||
"type": "function",
|
||||
"function": {"name": "web_search", "arguments": json.dumps({"query": "a"})},
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"id": "call_b",
|
||||
"type": "function",
|
||||
"function": {"name": "web_search", "arguments": json.dumps({"query": "b"})},
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
_done(),
|
||||
]
|
||||
final_stream = [_sse({"content": "Final answer."}), _done()]
|
||||
payloads: list[dict] = []
|
||||
backend = _make_backend(monkeypatch, [batch, final_stream], payloads)
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_execute_tool(name, arguments, **_kwargs):
|
||||
calls.append(arguments)
|
||||
return "search-result"
|
||||
|
||||
monkeypatch.setattr("core.inference.tools.execute_tool", fake_execute_tool)
|
||||
|
||||
events = list(
|
||||
backend.generate_chat_completion_with_tools(
|
||||
messages = [{"role": "user", "content": "search"}],
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
||||
max_tool_iterations = 3,
|
||||
)
|
||||
)
|
||||
|
||||
# Both distinct calls ran; the duplicate did not (old `break` dropped search(b)).
|
||||
assert calls == [{"query": "a"}, {"query": "b"}]
|
||||
assert [e.get("tool_call_id") for e in events if e.get("type") == "tool_end"] == [
|
||||
"call_a1",
|
||||
"call_b",
|
||||
]
|
||||
|
||||
# The next generation's conversation must be well-formed: the assistant lists
|
||||
# only the executed calls (no orphan for the duplicate), the two tool results
|
||||
# follow contiguously, and the no-op nudge lands after them, never between.
|
||||
conv = payloads[1]["messages"]
|
||||
asst = next(m for m in conv if m["role"] == "assistant" and m.get("tool_calls"))
|
||||
assert [tc.get("id") for tc in asst["tool_calls"]] == ["call_a1", "call_b"]
|
||||
after = conv[conv.index(asst) + 1 :]
|
||||
assert [m["role"] for m in after[:2]] == ["tool", "tool"]
|
||||
assert [m.get("tool_call_id") for m in after[:2]] == ["call_a1", "call_b"]
|
||||
assert after[2]["role"] == "user" # deferred duplicate nudge, after the results
|
||||
assert after[2]["content"].startswith(
|
||||
"One earlier request to call tool 'web_search' in this batch was not executed"
|
||||
)
|
||||
assert "previous tool request" not in after[2]["content"].lower()
|
||||
|
||||
|
||||
def test_same_turn_repeated_render_html_does_not_emit_second_provisional_start(monkeypatch):
|
||||
same_turn_render_calls = [
|
||||
_sse(
|
||||
|
|
|
|||
|
|
@ -373,6 +373,7 @@ def test_kill_orphaned_servers_returns_count():
|
|||
with (
|
||||
patch.dict(sys.modules, {"psutil": fake_psutil}),
|
||||
patch.dict(os.environ, {"LLAMA_SERVER_PATH": fake_path}),
|
||||
patch.object(LlamaCppBackend, "_pid_parent_is_alive", staticmethod(lambda pid: False)),
|
||||
):
|
||||
n = LlamaCppBackend._kill_orphaned_servers()
|
||||
assert n == 1, "only the Studio-owned orphan should be counted"
|
||||
|
|
@ -384,11 +385,53 @@ def test_kill_orphaned_servers_returns_count():
|
|||
with (
|
||||
patch.dict(sys.modules, {"psutil": fake_psutil}),
|
||||
patch.dict(os.environ, {"LLAMA_SERVER_PATH": fake_path}),
|
||||
patch.object(LlamaCppBackend, "_pid_parent_is_alive", staticmethod(lambda pid: False)),
|
||||
):
|
||||
assert LlamaCppBackend._kill_orphaned_servers() == 0
|
||||
assert killed == []
|
||||
|
||||
|
||||
def test_kill_orphaned_servers_spares_live_parent():
|
||||
"""A Studio-owned llama-server whose parent is still running is not an
|
||||
orphan (a live Studio or the user's shell owns it) and must never be
|
||||
killed; only the true orphan (parent gone) is reaped."""
|
||||
import os
|
||||
|
||||
mypid = os.getpid()
|
||||
fake_path = "/tmp/unsloth-test-llama/llama-server"
|
||||
killed: list[int] = []
|
||||
|
||||
class _FakeProc:
|
||||
def __init__(self, pid, name, exe):
|
||||
self.info = {"pid": pid, "name": name, "exe": exe}
|
||||
|
||||
def kill(self):
|
||||
killed.append(self.info["pid"])
|
||||
|
||||
live_parent = _FakeProc(mypid + 1, "llama-server", fake_path)
|
||||
true_orphan = _FakeProc(mypid + 2, "llama-server", fake_path)
|
||||
|
||||
fake_psutil = _types.ModuleType("psutil")
|
||||
fake_psutil.NoSuchProcess = type("NoSuchProcess", (Exception,), {})
|
||||
fake_psutil.AccessDenied = type("AccessDenied", (Exception,), {})
|
||||
fake_psutil.ZombieProcess = type("ZombieProcess", (Exception,), {})
|
||||
fake_psutil.process_iter = lambda attrs = None: [live_parent, true_orphan]
|
||||
|
||||
with (
|
||||
patch.dict(sys.modules, {"psutil": fake_psutil}),
|
||||
patch.dict(os.environ, {"LLAMA_SERVER_PATH": fake_path}),
|
||||
patch.object(LlamaCppBackend, "_reap_recorded_pid", staticmethod(lambda: 0)),
|
||||
patch.object(
|
||||
LlamaCppBackend,
|
||||
"_pid_parent_is_alive",
|
||||
staticmethod(lambda pid: pid == mypid + 1),
|
||||
),
|
||||
):
|
||||
n = LlamaCppBackend._kill_orphaned_servers()
|
||||
assert n == 1, "only the true orphan should be reaped"
|
||||
assert killed == [mypid + 2], "the live-parent server must be spared"
|
||||
|
||||
|
||||
def test_startup_reaper_arms_settle_timestamp():
|
||||
"""__init__ arms ``_last_kill_monotonic`` when the startup reaper kills an
|
||||
orphan (so the first load_model waits for VRAM to settle), and leaves the
|
||||
|
|
|
|||
290
studio/backend/tests/test_mcp_server.py
Normal file
290
studio/backend/tests/test_mcp_server.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# 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
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_server import BearerTokenMiddleware, _clamp, _dump, create_studio_mcp
|
||||
|
||||
|
||||
def _get_tool(name):
|
||||
tools = asyncio.run(create_studio_mcp().list_tools())
|
||||
return {tool.name: tool for tool in tools}[name]
|
||||
|
||||
|
||||
def test_studio_mcp_registers_control_plane_tools():
|
||||
tools = asyncio.run(create_studio_mcp().list_tools())
|
||||
|
||||
assert {tool.name for tool in tools} == {
|
||||
"studio_status",
|
||||
"list_local_models",
|
||||
"get_training_status",
|
||||
"start_training",
|
||||
"stop_training",
|
||||
"list_training_runs",
|
||||
"validate_recipe",
|
||||
"get_recipe_job_status",
|
||||
"get_recipe_job_dataset",
|
||||
"load_checkpoint",
|
||||
"export_gguf",
|
||||
}
|
||||
|
||||
|
||||
def test_dump_serializes_pydantic_values():
|
||||
class Response:
|
||||
def model_dump(self, *, mode):
|
||||
assert mode == "json"
|
||||
return {"ok": True}
|
||||
|
||||
assert _dump(Response()) == {"ok": True}
|
||||
assert _dump({"already": "json"}) == {"already": "json"}
|
||||
|
||||
|
||||
def test_bearer_token_middleware_rejects_wrong_token():
|
||||
events = []
|
||||
|
||||
async def app(scope, receive, send):
|
||||
events.append("app")
|
||||
|
||||
async def send(message):
|
||||
events.append(message)
|
||||
|
||||
middleware = BearerTokenMiddleware(app, "secret")
|
||||
asyncio.run(
|
||||
middleware(
|
||||
{"type": "http", "headers": [(b"authorization", b"Bearer wrong")]},
|
||||
None,
|
||||
send,
|
||||
)
|
||||
)
|
||||
|
||||
assert events[0]["status"] == 401
|
||||
assert "app" not in events
|
||||
|
||||
|
||||
def test_bearer_token_middleware_closes_unauthorized_websocket():
|
||||
events = []
|
||||
|
||||
async def app(scope, receive, send):
|
||||
events.append("app")
|
||||
|
||||
async def send(message):
|
||||
events.append(message)
|
||||
|
||||
middleware = BearerTokenMiddleware(app, "secret")
|
||||
asyncio.run(
|
||||
middleware(
|
||||
{"type": "websocket", "headers": []},
|
||||
None,
|
||||
send,
|
||||
)
|
||||
)
|
||||
|
||||
assert events == [{"type": "websocket.close", "code": 4401}]
|
||||
|
||||
|
||||
def test_bearer_token_middleware_rejects_non_ascii_authorization():
|
||||
# A non-ASCII bearer value must produce a clean 401, not a 500. Comparing on
|
||||
# bytes avoids the str hmac.compare_digest TypeError on non-ASCII input.
|
||||
events = []
|
||||
|
||||
async def app(scope, receive, send):
|
||||
events.append("app")
|
||||
|
||||
async def send(message):
|
||||
events.append(message)
|
||||
|
||||
middleware = BearerTokenMiddleware(app, "secret")
|
||||
asyncio.run(
|
||||
middleware(
|
||||
{"type": "http", "headers": [(b"authorization", b"Bearer \xff\xff")]},
|
||||
None,
|
||||
send,
|
||||
)
|
||||
)
|
||||
|
||||
assert events[0]["status"] == 401
|
||||
assert "app" not in events
|
||||
|
||||
|
||||
def test_bearer_token_middleware_accepts_correct_token():
|
||||
events = []
|
||||
|
||||
async def app(scope, receive, send):
|
||||
events.append("app")
|
||||
|
||||
async def send(message):
|
||||
events.append(message)
|
||||
|
||||
middleware = BearerTokenMiddleware(app, "secret")
|
||||
asyncio.run(
|
||||
middleware(
|
||||
{"type": "http", "headers": [(b"authorization", b"Bearer secret")]},
|
||||
None,
|
||||
send,
|
||||
)
|
||||
)
|
||||
|
||||
assert events == ["app"]
|
||||
|
||||
|
||||
def test_bearer_token_middleware_requires_non_empty_token():
|
||||
async def app(scope, receive, send):
|
||||
pass
|
||||
|
||||
for bad in ("", " "):
|
||||
with pytest.raises(ValueError):
|
||||
BearerTokenMiddleware(app, bad)
|
||||
|
||||
|
||||
def test_bearer_token_middleware_rejects_non_ascii_token():
|
||||
async def app(scope, receive, send):
|
||||
pass
|
||||
|
||||
# non-ASCII tokens cannot be transmitted in an HTTP header by a standard
|
||||
# client, so they are rejected at construction instead of locking out.
|
||||
for bad in ("töken", "\U0001f600"):
|
||||
with pytest.raises(ValueError):
|
||||
BearerTokenMiddleware(app, bad)
|
||||
|
||||
|
||||
def test_bearer_token_middleware_passes_through_non_http_scopes():
|
||||
events = []
|
||||
|
||||
async def app(scope, receive, send):
|
||||
events.append("app")
|
||||
|
||||
async def send(message):
|
||||
events.append(message)
|
||||
|
||||
middleware = BearerTokenMiddleware(app, "secret")
|
||||
asyncio.run(middleware({"type": "lifespan"}, None, send))
|
||||
|
||||
assert events == ["app"]
|
||||
|
||||
|
||||
def test_clamp_restricts_to_inclusive_bounds():
|
||||
assert _clamp(5, 1, 200) == 5
|
||||
assert _clamp(-10, 1, 200) == 1
|
||||
assert _clamp(10_000, 1, 200) == 200
|
||||
assert _clamp(0, 1, 500) == 1
|
||||
assert _clamp(1_000, 1, 500) == 500
|
||||
|
||||
|
||||
def test_export_and_checkpoint_tools_expose_forwarded_fields():
|
||||
export_props = set(_get_tool("export_gguf").parameters["properties"])
|
||||
assert {"hf_token", "imatrix", "imatrix_path"} <= export_props
|
||||
|
||||
checkpoint_props = set(_get_tool("load_checkpoint").parameters["properties"])
|
||||
assert {"hf_token", "approved_remote_code_fingerprint"} <= checkpoint_props
|
||||
|
||||
|
||||
def _stub_module(monkeypatch, name, **attrs):
|
||||
module = types.ModuleType(name)
|
||||
for key, value in attrs.items():
|
||||
setattr(module, key, value)
|
||||
if "." in name:
|
||||
module.__path__ = [] # mark package-like so submodule imports resolve
|
||||
monkeypatch.setitem(sys.modules, name, module)
|
||||
return module
|
||||
|
||||
|
||||
def test_export_gguf_forwards_hf_token_and_imatrix(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeExportGGUFRequest:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
async def fake_export(request, current_subject):
|
||||
return {"current_subject": current_subject}
|
||||
|
||||
_stub_module(monkeypatch, "models", ExportGGUFRequest = FakeExportGGUFRequest)
|
||||
_stub_module(monkeypatch, "routes")
|
||||
_stub_module(monkeypatch, "routes.export", export_gguf = fake_export)
|
||||
|
||||
tool = _get_tool("export_gguf")
|
||||
result = asyncio.run(
|
||||
tool.fn(
|
||||
save_directory = "/tmp/out",
|
||||
quantization_method = ["Q4_K_M", "Q8_0"],
|
||||
push_to_hub = True,
|
||||
repo_id = "me/model",
|
||||
hf_token = "hf_secret",
|
||||
imatrix = True,
|
||||
imatrix_path = "/tmp/imatrix.dat",
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["hf_token"] == "hf_secret"
|
||||
assert captured["imatrix"] is True
|
||||
assert captured["imatrix_path"] == "/tmp/imatrix.dat"
|
||||
assert captured["quantization_method"] == ["Q4_K_M", "Q8_0"]
|
||||
assert result["current_subject"] == "mcp"
|
||||
|
||||
|
||||
def test_load_checkpoint_forwards_token_and_fingerprint(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
class FakeLoadCheckpointRequest:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
async def fake_load(request, current_subject):
|
||||
return {"current_subject": current_subject}
|
||||
|
||||
_stub_module(monkeypatch, "models", LoadCheckpointRequest = FakeLoadCheckpointRequest)
|
||||
_stub_module(monkeypatch, "routes")
|
||||
_stub_module(monkeypatch, "routes.export", load_checkpoint = fake_load)
|
||||
|
||||
tool = _get_tool("load_checkpoint")
|
||||
asyncio.run(
|
||||
tool.fn(
|
||||
checkpoint_path = "/tmp/ckpt",
|
||||
approved_remote_code_fingerprint = "sha256:abc",
|
||||
hf_token = "hf_secret",
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["hf_token"] == "hf_secret"
|
||||
assert captured["approved_remote_code_fingerprint"] == "sha256:abc"
|
||||
|
||||
|
||||
def test_list_training_runs_clamps_pagination(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_list_runs(limit, offset, current_subject):
|
||||
captured["limit"] = limit
|
||||
captured["offset"] = offset
|
||||
return {"ok": True}
|
||||
|
||||
_stub_module(monkeypatch, "routes")
|
||||
_stub_module(monkeypatch, "routes.training_history", list_training_runs = fake_list_runs)
|
||||
|
||||
tool = _get_tool("list_training_runs")
|
||||
asyncio.run(tool.fn(limit = 10_000, offset = -5))
|
||||
|
||||
assert captured["limit"] == 200
|
||||
assert captured["offset"] == 0
|
||||
|
||||
|
||||
def test_get_recipe_job_dataset_clamps_pagination(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_job_dataset(job_id, limit, offset):
|
||||
captured["limit"] = limit
|
||||
captured["offset"] = offset
|
||||
return {"ok": True}
|
||||
|
||||
_stub_module(monkeypatch, "routes")
|
||||
_stub_module(monkeypatch, "routes.data_recipe")
|
||||
_stub_module(monkeypatch, "routes.data_recipe.jobs", job_dataset = fake_job_dataset)
|
||||
|
||||
tool = _get_tool("get_recipe_job_dataset") # this tool is synchronous
|
||||
tool.fn(job_id = "job-1", limit = -1, offset = -9)
|
||||
|
||||
assert captured["limit"] == 1
|
||||
assert captured["offset"] == 0
|
||||
|
|
@ -523,3 +523,169 @@ def test_mlx_generate_text_forwards_kwargs_into_template_helper(monkeypatch):
|
|||
assert render["kwargs"]["enable_thinking"] is True
|
||||
assert render["kwargs"]["reasoning_effort"] == "medium"
|
||||
assert render["kwargs"]["preserve_thinking"] is True
|
||||
|
||||
|
||||
def test_mlx_text_normalizes_native_reasoning_and_close_releases_lock(monkeypatch):
|
||||
_install_fake_mlx(monkeypatch)
|
||||
from core.inference.mlx_inference import MLXInferenceBackend
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.inference.chat_template_helpers.apply_chat_template_for_generation",
|
||||
lambda *_args, **_kwargs: "prompt",
|
||||
raising = True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.inference.chat_template_helpers.render_with_native_template_fallback",
|
||||
lambda formatted_prompt, **_kwargs: SimpleNamespace(
|
||||
prompt = formatted_prompt,
|
||||
reasoning_channel_markers = ("<|channel>thought\n", "<channel|>"),
|
||||
),
|
||||
raising = True,
|
||||
)
|
||||
|
||||
mlx_lm_pkg = types.ModuleType("mlx_lm")
|
||||
mlx_lm_sample = types.ModuleType("mlx_lm.sample_utils")
|
||||
mlx_lm_sample.make_sampler = lambda **_kw: object()
|
||||
mlx_lm_sample.make_logits_processors = lambda **_kw: None
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, text, tok):
|
||||
self.text = text
|
||||
self.token = tok
|
||||
|
||||
def _stream_generate(_model, _tokenizer, **_kw):
|
||||
yield _Resp("<|channel>thought\n", 10)
|
||||
yield _Resp("r", 11)
|
||||
yield _Resp("<channel|>", 12)
|
||||
yield _Resp("a", 13)
|
||||
|
||||
mlx_lm_pkg.stream_generate = _stream_generate
|
||||
monkeypatch.setitem(sys.modules, "mlx_lm", mlx_lm_pkg)
|
||||
monkeypatch.setitem(sys.modules, "mlx_lm.sample_utils", mlx_lm_sample)
|
||||
|
||||
backend = MLXInferenceBackend()
|
||||
backend._model = object()
|
||||
backend._tokenizer = SimpleNamespace(all_special_tokens = [])
|
||||
backend._is_vlm = False
|
||||
|
||||
assert list(
|
||||
backend.generate_chat_response(
|
||||
messages = [{"role": "user", "content": "ping"}],
|
||||
max_new_tokens = 4,
|
||||
)
|
||||
) == ["<think>", "<think>r", "<think>r</think>", "<think>r</think>a"]
|
||||
|
||||
gen = backend.generate_chat_response(
|
||||
messages = [{"role": "user", "content": "ping"}],
|
||||
max_new_tokens = 4,
|
||||
)
|
||||
assert next(gen) == "<think>"
|
||||
assert backend._generation_lock.locked()
|
||||
gen.close()
|
||||
assert not backend._generation_lock.locked()
|
||||
|
||||
|
||||
def test_mlx_text_native_metadata_preserves_prefilled_think_snapshots(monkeypatch):
|
||||
_install_fake_mlx(monkeypatch)
|
||||
from core.inference.mlx_inference import MLXInferenceBackend
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.inference.chat_template_helpers.apply_chat_template_for_generation",
|
||||
lambda *_args, **_kwargs: "prompt<think>\n",
|
||||
raising = True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.inference.chat_template_helpers.render_with_native_template_fallback",
|
||||
lambda formatted_prompt, **_kwargs: SimpleNamespace(
|
||||
prompt = formatted_prompt,
|
||||
reasoning_channel_markers = ("<|channel>thought", "<channel|>"),
|
||||
),
|
||||
raising = True,
|
||||
)
|
||||
|
||||
mlx_lm_pkg = types.ModuleType("mlx_lm")
|
||||
mlx_lm_sample = types.ModuleType("mlx_lm.sample_utils")
|
||||
mlx_lm_sample.make_sampler = lambda **_kw: object()
|
||||
mlx_lm_sample.make_logits_processors = lambda **_kw: None
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, text, tok):
|
||||
self.text = text
|
||||
self.token = tok
|
||||
|
||||
def _stream_generate(_model, _tokenizer, **_kw):
|
||||
yield _Resp("reason", 10)
|
||||
yield _Resp("</think>", 11)
|
||||
yield _Resp("answer", 12)
|
||||
|
||||
mlx_lm_pkg.stream_generate = _stream_generate
|
||||
monkeypatch.setitem(sys.modules, "mlx_lm", mlx_lm_pkg)
|
||||
monkeypatch.setitem(sys.modules, "mlx_lm.sample_utils", mlx_lm_sample)
|
||||
|
||||
backend = MLXInferenceBackend()
|
||||
backend._model = object()
|
||||
backend._tokenizer = SimpleNamespace(all_special_tokens = [])
|
||||
backend._is_vlm = False
|
||||
|
||||
snapshots = list(
|
||||
backend.generate_chat_response(
|
||||
messages = [{"role": "user", "content": "ping"}],
|
||||
max_new_tokens = 3,
|
||||
)
|
||||
)
|
||||
assert snapshots == [
|
||||
"<think>\n",
|
||||
"<think>\nreason",
|
||||
"<think>\nreason</think>",
|
||||
"<think>\nreason</think>answer",
|
||||
]
|
||||
assert all(current.startswith(previous) for previous, current in zip(snapshots, snapshots[1:]))
|
||||
|
||||
|
||||
def test_mlx_vlm_normalizes_native_reasoning_channels(monkeypatch):
|
||||
_install_fake_mlx(monkeypatch)
|
||||
from core.inference.mlx_inference import MLXInferenceBackend
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.inference.chat_template_helpers.apply_chat_template_for_generation",
|
||||
lambda *_args, **_kwargs: "prompt",
|
||||
raising = True,
|
||||
)
|
||||
|
||||
mlx_vlm_pkg = types.ModuleType("mlx_vlm")
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, text, tok):
|
||||
self.text = text
|
||||
self.token = tok
|
||||
|
||||
def _stream_generate(_model, _processor, _prompt, _images, **_kw):
|
||||
yield _Resp("<|channel>thought\n", 10)
|
||||
yield _Resp("vision", 11)
|
||||
yield _Resp("<channel|>", 12)
|
||||
yield _Resp(" answer", 13)
|
||||
|
||||
mlx_vlm_pkg.stream_generate = _stream_generate
|
||||
monkeypatch.setitem(sys.modules, "mlx_vlm", mlx_vlm_pkg)
|
||||
|
||||
backend = MLXInferenceBackend()
|
||||
backend._model = SimpleNamespace(config = SimpleNamespace())
|
||||
backend._processor = SimpleNamespace(
|
||||
chat_template = "<|channel>thought\n...<channel|>",
|
||||
all_special_tokens = [],
|
||||
apply_chat_template = lambda *_args, **_kwargs: "prompt",
|
||||
)
|
||||
backend._is_vlm = True
|
||||
|
||||
assert list(
|
||||
backend.generate_chat_response(
|
||||
messages = [{"role": "user", "content": "describe"}],
|
||||
image = object(),
|
||||
max_new_tokens = 4,
|
||||
)
|
||||
) == [
|
||||
"<think>",
|
||||
"<think>vision",
|
||||
"<think>vision</think>",
|
||||
"<think>vision</think> answer",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -328,6 +328,7 @@ def test_download_mtp_prefers_root_over_new_scheme_copies(monkeypatch):
|
|||
pick,
|
||||
label,
|
||||
cancel_event = None,
|
||||
near_path = None,
|
||||
):
|
||||
captured["pick"] = pick
|
||||
return None
|
||||
|
|
@ -428,6 +429,32 @@ def test_download_mtp_reuse_follows_snapshot_order_offline(tmp_path, monkeypatch
|
|||
assert got is not None and Path(got).parent.parent.name == "newest"
|
||||
|
||||
|
||||
def test_download_mtp_prefers_main_snapshot_offline(tmp_path, monkeypatch):
|
||||
import utils.models.model_config as mc
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
|
||||
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
||||
snapshots = tmp_path / "models--unsloth--gemma" / "snapshots"
|
||||
old = snapshots / "old"
|
||||
new = snapshots / "new"
|
||||
old.mkdir(parents = True)
|
||||
new.mkdir(parents = True)
|
||||
main = old / "gemma-UD-Q4_K_XL.gguf"
|
||||
old_drafter = old / "mtp-gemma.gguf"
|
||||
new_drafter = new / "mtp-gemma.gguf"
|
||||
main.write_bytes(b"main")
|
||||
old_drafter.write_bytes(b"old")
|
||||
new_drafter.write_bytes(b"new")
|
||||
monkeypatch.setattr(mc, "_iter_hf_cache_snapshots", lambda _repo: [new, old])
|
||||
|
||||
got = LlamaCppBackend()._download_mtp(
|
||||
hf_repo = "unsloth/gemma-GGUF",
|
||||
near_path = str(main),
|
||||
)
|
||||
|
||||
assert got == str(old_drafter)
|
||||
|
||||
|
||||
def test_download_mtp_online_skips_cache_reuse(tmp_path, monkeypatch):
|
||||
# Online, do not reuse a cached copy: go to the download path so a changed
|
||||
# drafter is refetched (hf_hub_download checks the current revision).
|
||||
|
|
@ -447,6 +474,7 @@ def test_download_mtp_online_skips_cache_reuse(tmp_path, monkeypatch):
|
|||
pick,
|
||||
label,
|
||||
cancel_event = None,
|
||||
near_path = None,
|
||||
):
|
||||
reached["hit"] = True
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -244,9 +244,7 @@ class TestGgufVariantFileResolution:
|
|||
def test_download_reuses_older_snapshot_when_current_ref_snapshot_is_partial(
|
||||
self, monkeypatch, hf_cache
|
||||
):
|
||||
# Cross-snapshot reuse is an offline-resilience path: online, hf_hub_download
|
||||
# resumes the partial current-ref download and revalidates the revision instead
|
||||
# of serving an older snapshot's same-name blob.
|
||||
# Keep coverage for offline reuse; online reuse is tested separately.
|
||||
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
||||
backend = LlamaCppBackend()
|
||||
repo = "unsloth/vision-GGUF"
|
||||
|
|
@ -292,8 +290,7 @@ class TestGgufVariantFileResolution:
|
|||
def test_download_reuses_cached_gguf_when_lowercase_partial_cache_shadows_it(
|
||||
self, monkeypatch, hf_cache
|
||||
):
|
||||
# Case-variant cross-dir reuse is offline-only; online the canonical repo id
|
||||
# resolves up front and hf_hub_download fetches the current revision.
|
||||
# Keep coverage for case-insensitive offline cache lookup.
|
||||
monkeypatch.setenv("HF_HUB_OFFLINE", "1")
|
||||
backend = LlamaCppBackend()
|
||||
canonical_repo = "unsloth/gemma-4-E2B-it-GGUF"
|
||||
|
|
@ -348,45 +345,26 @@ class TestGgufVariantFileResolution:
|
|||
assert out == str(snap / gguf_file)
|
||||
assert seen_repos
|
||||
|
||||
def test_download_online_does_not_reuse_old_snapshot(self, monkeypatch, hf_cache):
|
||||
# Online, an older same-name snapshot must not be served (it may be a stale
|
||||
# revision); hf_hub_download is called so the current revision is fetched and
|
||||
# its etag revalidated.
|
||||
def test_download_online_reuses_complete_cached_snapshot(self, monkeypatch, hf_cache):
|
||||
# Loads reuse complete cached models across repo revisions.
|
||||
monkeypatch.delenv("HF_HUB_OFFLINE", raising = False)
|
||||
backend = LlamaCppBackend()
|
||||
repo = "unsloth/vision-GGUF"
|
||||
_build_cache(hf_cache, repo, {"model-UD-Q4_K_XL.gguf": 4}, snapshot_sha = "a" * 40)
|
||||
downloaded: list[str] = []
|
||||
snap = _build_cache(hf_cache, repo, {"model-UD-Q4_K_XL.gguf": 4}, snapshot_sha = "a" * 40)
|
||||
|
||||
def fake_get_paths_info(
|
||||
_repo_id,
|
||||
paths,
|
||||
token = None,
|
||||
):
|
||||
return [_types.SimpleNamespace(path = p, size = 4) for p in paths if p]
|
||||
|
||||
def fake_download(
|
||||
repo_id,
|
||||
filename,
|
||||
token = None,
|
||||
**kwargs,
|
||||
):
|
||||
downloaded.append(filename)
|
||||
return f"/fresh/{filename}"
|
||||
def fail_download(*_args, **_kwargs):
|
||||
raise AssertionError("must reuse the cached GGUF instead of downloading")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"huggingface_hub.list_repo_files",
|
||||
lambda *_a, **_k: ["model-UD-Q4_K_XL.gguf"],
|
||||
),
|
||||
patch("huggingface_hub.get_paths_info", fake_get_paths_info),
|
||||
patch("huggingface_hub.try_to_load_from_cache", lambda *_a, **_k: None),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fake_download),
|
||||
patch("core.inference.llama_cpp.hf_hub_download_with_xet_fallback", fail_download),
|
||||
):
|
||||
out = backend._download_gguf(hf_repo = repo, hf_variant = "UD-Q4_K_XL")
|
||||
|
||||
assert downloaded == ["model-UD-Q4_K_XL.gguf"]
|
||||
assert out == "/fresh/model-UD-Q4_K_XL.gguf"
|
||||
assert out == str(snap / "model-UD-Q4_K_XL.gguf")
|
||||
|
||||
def test_download_reuses_older_snapshot_when_offline_env_is_true(self, monkeypatch, hf_cache):
|
||||
# HF_HUB_OFFLINE accepts truthy spellings beyond "1" (true/yes/on); the offline
|
||||
|
|
|
|||
|
|
@ -1054,7 +1054,7 @@ class TestChatCompletionRequestToolFields:
|
|||
monkeypatch.setattr(
|
||||
inference_route,
|
||||
"_detect_safetensors_features",
|
||||
lambda backend, chat_template: {"supports_tools": True},
|
||||
lambda backend, chat_template, tools = None: {"supports_tools": True},
|
||||
)
|
||||
monitor = ApiMonitor(max_entries = 3)
|
||||
monkeypatch.setattr(inference_route, "api_monitor", monitor)
|
||||
|
|
@ -6620,6 +6620,29 @@ class TestApiMonitorAudioInput:
|
|||
assert entry["reply"] == "hello world"
|
||||
assert monitor.active_count() == 0
|
||||
|
||||
def failing_chunks():
|
||||
yield "partial"
|
||||
raise RuntimeError("generation failed")
|
||||
|
||||
self._patch_audio_backend(monkeypatch, failing_chunks())
|
||||
error_monitor = ApiMonitor(max_entries = 3)
|
||||
monkeypatch.setattr(inf_mod, "api_monitor", error_monitor)
|
||||
error_response = await openai_chat_completions(
|
||||
payload,
|
||||
request = request,
|
||||
current_subject = "test",
|
||||
)
|
||||
error_chunks = [
|
||||
chunk.decode() if isinstance(chunk, bytes) else chunk
|
||||
async for chunk in error_response.body_iterator
|
||||
]
|
||||
|
||||
assert '"type": "server_error"' in error_chunks[-1]
|
||||
assert error_chunks[-1].endswith("data: [DONE]\n\n")
|
||||
[error_entry] = error_monitor.snapshot()
|
||||
assert error_entry["status"] == "error"
|
||||
assert error_monitor.active_count() == 0
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
def test_non_gguf_tts_auto_route_records_monitor(self, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -54,6 +54,56 @@ def test_pdf_markdown_off_uses_plain_text(tmp_path, monkeypatch):
|
|||
assert "#" not in text and "|" not in text # plain text path emits no Markdown markup
|
||||
|
||||
|
||||
def test_pdf_bytes_use_same_extraction_path(tmp_path, monkeypatch):
|
||||
from core.rag import config, parsers
|
||||
|
||||
monkeypatch.setattr(config, "PDF_MARKDOWN", False)
|
||||
pdf = tmp_path / "table.pdf"
|
||||
_table_pdf(pdf)
|
||||
from_file = parsers.parse(str(pdf))
|
||||
from_bytes, total_pages = parsers.parse_pdf_bytes(pdf.read_bytes())
|
||||
assert [page.text for page in from_bytes] == [page.text for page in from_file]
|
||||
assert total_pages == len(from_file)
|
||||
|
||||
|
||||
def test_pdf_bytes_limit_pages_before_extraction(monkeypatch):
|
||||
import pymupdf
|
||||
|
||||
from core.rag import config, parsers
|
||||
|
||||
monkeypatch.setattr(config, "PDF_MARKDOWN", False)
|
||||
doc = pymupdf.open()
|
||||
for marker in ("page one", "page two", "page three"):
|
||||
page = doc.new_page()
|
||||
page.insert_text((40, 40), marker)
|
||||
data = doc.tobytes()
|
||||
doc.close()
|
||||
|
||||
pages, total_pages = parsers.parse_pdf_bytes(data, max_pages = 2)
|
||||
assert len(pages) == 2
|
||||
assert "page two" in pages[-1].text
|
||||
assert total_pages == 3 # full count, not the 2 extracted
|
||||
|
||||
|
||||
def test_pdf_markdown_receives_page_limit(monkeypatch):
|
||||
from core.rag import parsers
|
||||
|
||||
captured = {}
|
||||
|
||||
class _FakePymupdf4llm:
|
||||
@staticmethod
|
||||
def to_markdown(doc, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return [{"text": "page"} for _ in kwargs["pages"]]
|
||||
|
||||
class _Doc:
|
||||
page_count = 100
|
||||
|
||||
monkeypatch.setitem(__import__("sys").modules, "pymupdf4llm", _FakePymupdf4llm)
|
||||
assert parsers._pdf_markdown(_Doc(), range(2)) == ["page", "page"]
|
||||
assert captured == {"page_chunks": True, "show_progress": False, "pages": [0, 1]}
|
||||
|
||||
|
||||
def test_pdf_markdown_passes_only_supported_legacy_kwargs(monkeypatch):
|
||||
# The pinned PyMuPDF4LLM legacy path ignores unknown kwargs; do not pass the
|
||||
# newer layout-only OCR knobs or Markdown extraction silently loses policy control.
|
||||
|
|
|
|||
|
|
@ -417,6 +417,59 @@ def test_detect_safetensors_features_gemma_native_tool_call_keeps_tools_on():
|
|||
assert flags["supports_tools"] is True
|
||||
|
||||
|
||||
def test_detect_safetensors_features_gemma_native_reasoning_is_parseable_not_prefilled():
|
||||
"""Native Gemma channels are normalized to <think>, then split by the route."""
|
||||
from routes.inference import _detect_safetensors_features, _sf_reasoning_prefill_mode
|
||||
|
||||
tpl_with_gemma_native = "{% if add_generation_prompt %}<|channel>thought\n<channel|>{% endif %}"
|
||||
backend = SimpleNamespace(
|
||||
active_model_name = "unsloth/gemma-4-E2B-it",
|
||||
models = {
|
||||
"unsloth/gemma-4-E2B-it": {
|
||||
"native_chat_template": tpl_with_gemma_native,
|
||||
"chat_template_info": {"template": "override has no native markers"},
|
||||
}
|
||||
},
|
||||
)
|
||||
flags = _detect_safetensors_features(backend, "override has no native markers")
|
||||
missing_arg_flags = _detect_safetensors_features(backend, None)
|
||||
|
||||
assert flags["supports_reasoning"] is True
|
||||
assert flags["reasoning_always_on"] is True
|
||||
assert missing_arg_flags["supports_reasoning"] is True
|
||||
assert _sf_reasoning_prefill_mode(flags, None, tpl_with_gemma_native) is False
|
||||
|
||||
|
||||
def test_detect_safetensors_features_selects_native_reasoning_from_tool_template():
|
||||
"""Request tools select a marker-bearing named template without affecting default chat."""
|
||||
from routes.inference import _detect_safetensors_features
|
||||
|
||||
named_template = {
|
||||
"default": "plain default template",
|
||||
"tool_use": "{% if tools %}<|channel>thought\n<channel|>{% endif %}",
|
||||
}
|
||||
backend = SimpleNamespace(
|
||||
active_model_name = "custom/named-native-reasoning",
|
||||
models = {
|
||||
"custom/named-native-reasoning": {
|
||||
"native_chat_template": named_template,
|
||||
"chat_template_info": {"template": "{% if tools %}<tool_call>{% endif %}"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
default_flags = _detect_safetensors_features(backend, "plain override")
|
||||
tool_flags = _detect_safetensors_features(
|
||||
backend,
|
||||
"plain override",
|
||||
tools = [{"type": "function"}],
|
||||
)
|
||||
|
||||
assert default_flags["supports_reasoning"] is False
|
||||
assert tool_flags["supports_reasoning"] is True
|
||||
assert tool_flags["reasoning_always_on"] is True
|
||||
|
||||
|
||||
# Qwen3.5 family pin: the live GGUF + safetensors templates both wrap tool
|
||||
# calls as ``<tool_call>\n<function=name>...``. Faithful slice so the
|
||||
# classifier never silently regresses for this family.
|
||||
|
|
|
|||
|
|
@ -215,3 +215,135 @@ def test_s6_reasoning_effort_none_disables_prefill_for_enable_thinking_effort():
|
|||
swallowed = _replay_sf_reasoning_stream(events, prefilled = True)
|
||||
assert swallowed["visible"] == ""
|
||||
assert swallowed["reasoning"] == "The capital of France is Paris."
|
||||
|
||||
|
||||
def test_native_reasoning_streamer_selected_and_errors_raise():
|
||||
import threading
|
||||
import pytest
|
||||
|
||||
torch = pytest.importorskip("torch")
|
||||
inf = pytest.importorskip("core.inference.inference")
|
||||
|
||||
class Batch(dict):
|
||||
def to(self, _device):
|
||||
return self
|
||||
|
||||
class Tok:
|
||||
chat_template = "<|channel>thought\n...<channel|>"
|
||||
all_special_tokens = []
|
||||
eos_token_id = 1
|
||||
pad_token_id = None
|
||||
pieces = {10: "<|channel>thought\n", 11: "r", 12: "<channel|>", 13: "a"}
|
||||
|
||||
def __call__(self, *_args, **_kwargs):
|
||||
return Batch({"input_ids": torch.zeros((1, 1), dtype = torch.long)})
|
||||
|
||||
def decode(self, ids, **_kwargs):
|
||||
return "".join(self.pieces.get(int(token_id), "") for token_id in ids)
|
||||
|
||||
class Model:
|
||||
device = "cpu"
|
||||
generation_config = type("Cfg", (), {"eos_token_id": 1})()
|
||||
config = generation_config
|
||||
|
||||
def __init__(self, fail = False):
|
||||
self.fail = fail
|
||||
self.kwargs = None
|
||||
|
||||
def generate(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
streamer = kwargs["streamer"]
|
||||
streamer.put(torch.zeros((1, 1), dtype = torch.long))
|
||||
for token_id in [10, 11, 12, 13]:
|
||||
streamer.put(torch.tensor([token_id]))
|
||||
if self.fail:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
backend = inf.InferenceBackend.__new__(inf.InferenceBackend)
|
||||
backend.active_model_name = "gemma-test"
|
||||
backend._generation_lock = threading.Lock()
|
||||
backend.models = {"gemma-test": {"model": Model(), "tokenizer": Tok()}}
|
||||
|
||||
assert list(backend.generate_stream("prompt", max_new_tokens = 4))[-1] == "<think>r</think>a"
|
||||
|
||||
backend.models["gemma-test"]["model"] = Model(fail = True)
|
||||
|
||||
with pytest.raises(inf._GenerationThreadError, match = "boom"):
|
||||
list(backend.generate_stream("prompt", max_new_tokens = 4))
|
||||
|
||||
|
||||
def test_text_only_vlm_fallback_resolves_native_markers_off():
|
||||
import threading
|
||||
import pytest
|
||||
|
||||
torch = pytest.importorskip("torch")
|
||||
inf = pytest.importorskip("core.inference.inference")
|
||||
|
||||
class Batch(dict):
|
||||
def to(self, _device):
|
||||
return self
|
||||
|
||||
class Tokenizer:
|
||||
all_special_tokens = []
|
||||
eos_token_id = 1
|
||||
pad_token_id = None
|
||||
|
||||
def __call__(self, *_args, **_kwargs):
|
||||
return Batch({"input_ids": torch.zeros((1, 1), dtype = torch.long)})
|
||||
|
||||
class Processor:
|
||||
chat_template = "<|channel>thought\n...<channel|>"
|
||||
tokenizer = Tokenizer()
|
||||
|
||||
class Model:
|
||||
device = "cpu"
|
||||
generation_config = type("Cfg", (), {"eos_token_id": 1})()
|
||||
config = generation_config
|
||||
|
||||
def generate(self, **_kwargs):
|
||||
return None
|
||||
|
||||
class EmptyStreamer:
|
||||
def __next__(self):
|
||||
raise StopIteration
|
||||
|
||||
def end(self):
|
||||
return None
|
||||
|
||||
captured = {}
|
||||
backend = inf.InferenceBackend.__new__(inf.InferenceBackend)
|
||||
backend.active_model_name = "vision-test"
|
||||
backend._generation_lock = threading.Lock()
|
||||
backend.models = {
|
||||
"vision-test": {
|
||||
"model": Model(),
|
||||
"processor": Processor(),
|
||||
"tokenizer": Processor(),
|
||||
}
|
||||
}
|
||||
backend.format_chat_prompt = lambda *_args, **_kwargs: "manual text-only prompt"
|
||||
|
||||
def make_streamer(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return EmptyStreamer()
|
||||
|
||||
backend._make_text_streamer = make_streamer
|
||||
|
||||
assert (
|
||||
list(
|
||||
backend._generate_vision_response(
|
||||
messages = [{"role": "user", "content": "hello"}],
|
||||
system_prompt = "",
|
||||
image = None,
|
||||
temperature = 0.7,
|
||||
top_p = 0.9,
|
||||
top_k = 40,
|
||||
min_p = 0.0,
|
||||
max_new_tokens = 1,
|
||||
repetition_penalty = 1.0,
|
||||
)
|
||||
)
|
||||
== []
|
||||
)
|
||||
assert captured["reasoning_channel_markers"] is None
|
||||
assert captured["reasoning_channel_markers_resolved"] is True
|
||||
|
|
|
|||
|
|
@ -2843,6 +2843,61 @@ class TestLoopBehaviour:
|
|||
]
|
||||
assert len(duplicate_nudges) == 1
|
||||
|
||||
def test_same_turn_duplicate_does_not_drop_later_parallel_call(self):
|
||||
# Turn 1 runs search(x). Turn 2's batch is [search(x) duplicate, python]:
|
||||
# the duplicate is a no-op, but python after it must still run, and the
|
||||
# no-op nudge must land after python's result rather than splitting it.
|
||||
captured_messages: list[list[dict]] = []
|
||||
turns = iter(
|
||||
[
|
||||
['<tool_call>{"name":"web_search","arguments":{"query":"x"}}</tool_call>'],
|
||||
[
|
||||
'<tool_call>{"name":"web_search","arguments":{"query":"x"}}</tool_call>'
|
||||
'<tool_call>{"name":"python","arguments":{"code":"print(1)"}}</tool_call>'
|
||||
],
|
||||
["final"],
|
||||
]
|
||||
)
|
||||
|
||||
def fake_single_turn(messages, active_tools = None):
|
||||
captured_messages.append([dict(m) for m in messages])
|
||||
chunks = next(turns)
|
||||
acc = ""
|
||||
for chunk in chunks:
|
||||
acc += chunk
|
||||
yield acc
|
||||
|
||||
exec_fn = FakeExecuteTool(["search-x", "py-result"])
|
||||
_collect_events(
|
||||
run_safetensors_tool_loop(
|
||||
single_turn = fake_single_turn,
|
||||
messages = [{"role": "user", "content": "hi"}],
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "web_search"}},
|
||||
{"type": "function", "function": {"name": "python"}},
|
||||
],
|
||||
execute_tool = exec_fn,
|
||||
max_tool_iterations = 4,
|
||||
)
|
||||
)
|
||||
|
||||
# Turn-1 search and turn-2 python both ran; the turn-2 duplicate search did not.
|
||||
assert exec_fn.calls == [
|
||||
("web_search", {"query": "x"}),
|
||||
("python", {"code": "print(1)"}),
|
||||
]
|
||||
|
||||
conv = captured_messages[-1]
|
||||
turn2 = [m for m in conv if m.get("role") == "assistant" and m.get("tool_calls")][-1]
|
||||
assert [tc["function"]["name"] for tc in turn2["tool_calls"]] == ["python"]
|
||||
after = conv[conv.index(turn2) + 1 :]
|
||||
assert after[0]["role"] == "tool" and after[0]["content"] == "py-result"
|
||||
assert after[1]["role"] == "user" # deferred duplicate nudge, after the result
|
||||
assert after[1]["content"].startswith(
|
||||
"One earlier request to call tool 'web_search' in this batch was not executed"
|
||||
)
|
||||
assert "previous tool request" not in after[1]["content"].lower()
|
||||
|
||||
def test_duplicate_tool_call_internal_noop_allows_distinct_followup_tool(self):
|
||||
captured_messages: list[list[dict]] = []
|
||||
captured_tool_names: list[list[str]] = []
|
||||
|
|
@ -3150,6 +3205,196 @@ class TestLoopBehaviour:
|
|||
class TestLoopRePrompt:
|
||||
"""Plan-without-action re-prompt parity with GGUF: nudge instead of terminating, up to ``MAX_ACT_REPROMPTS`` extra slots. Studio always nudges, so these drive the loop with ``nudge_tool_calls=True``."""
|
||||
|
||||
def test_reasoning_intent_does_not_reprompt_a_visible_answer(self):
|
||||
generations = 0
|
||||
|
||||
def _gen(_messages, active_tools = None):
|
||||
nonlocal generations
|
||||
generations += 1
|
||||
yield (
|
||||
"<think>Let me prepare the requested summary carefully.</think>"
|
||||
"This is the final visible answer."
|
||||
)
|
||||
|
||||
exec_fn = FakeExecuteTool([])
|
||||
events = _collect_events(
|
||||
run_safetensors_tool_loop(
|
||||
single_turn = _gen,
|
||||
messages = [{"role": "user", "content": "summarize this"}],
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
||||
execute_tool = exec_fn,
|
||||
nudge_tool_calls = True,
|
||||
)
|
||||
)
|
||||
|
||||
assert generations == 1
|
||||
assert exec_fn.calls == []
|
||||
contents = [e["text"] for e in events if e["type"] == "content"]
|
||||
assert contents[-1].endswith("This is the final visible answer.")
|
||||
|
||||
def test_prefilled_reasoning_intent_does_not_reprompt_a_visible_answer(self):
|
||||
generations = 0
|
||||
|
||||
def _gen(_messages, active_tools = None):
|
||||
nonlocal generations
|
||||
generations += 1
|
||||
yield "Let me prepare the requested summary carefully.</think>This is the final visible answer."
|
||||
|
||||
exec_fn = FakeExecuteTool([])
|
||||
events = _collect_events(
|
||||
run_safetensors_tool_loop(
|
||||
single_turn = _gen,
|
||||
messages = [{"role": "user", "content": "summarize this"}],
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
||||
execute_tool = exec_fn,
|
||||
nudge_tool_calls = True,
|
||||
reasoning_prefilled = True,
|
||||
)
|
||||
)
|
||||
|
||||
assert generations == 1
|
||||
assert exec_fn.calls == []
|
||||
contents = [e["text"] for e in events if e["type"] == "content"]
|
||||
assert contents[-1].endswith("This is the final visible answer.")
|
||||
|
||||
def test_prefilled_reasoning_with_reemitted_think_does_not_reprompt(self):
|
||||
generations = 0
|
||||
|
||||
def _gen(_messages, active_tools = None):
|
||||
nonlocal generations
|
||||
generations += 1
|
||||
yield (
|
||||
"Let me prepare the requested summary carefully."
|
||||
"<think>more private planning</think>This is the final visible answer."
|
||||
)
|
||||
|
||||
exec_fn = FakeExecuteTool([])
|
||||
events = _collect_events(
|
||||
run_safetensors_tool_loop(
|
||||
single_turn = _gen,
|
||||
messages = [{"role": "user", "content": "summarize this"}],
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
||||
execute_tool = exec_fn,
|
||||
nudge_tool_calls = True,
|
||||
reasoning_prefilled = True,
|
||||
)
|
||||
)
|
||||
|
||||
assert generations == 1
|
||||
assert exec_fn.calls == []
|
||||
contents = [e["text"] for e in events if e["type"] == "content"]
|
||||
assert contents[-1].endswith("This is the final visible answer.")
|
||||
|
||||
def test_prefilled_reasoning_with_later_think_does_not_reprompt(self):
|
||||
generations = 0
|
||||
|
||||
def _gen(_messages, active_tools = None):
|
||||
nonlocal generations
|
||||
generations += 1
|
||||
yield (
|
||||
"private prefilled planning</think>"
|
||||
"<think>Let me prepare the requested summary carefully.</think>"
|
||||
"This is the final visible answer."
|
||||
)
|
||||
|
||||
exec_fn = FakeExecuteTool([])
|
||||
events = _collect_events(
|
||||
run_safetensors_tool_loop(
|
||||
single_turn = _gen,
|
||||
messages = [{"role": "user", "content": "summarize this"}],
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
||||
execute_tool = exec_fn,
|
||||
nudge_tool_calls = True,
|
||||
reasoning_prefilled = True,
|
||||
)
|
||||
)
|
||||
|
||||
assert generations == 1
|
||||
assert exec_fn.calls == []
|
||||
contents = [e["text"] for e in events if e["type"] == "content"]
|
||||
assert contents[-1].endswith("This is the final visible answer.")
|
||||
|
||||
def test_reasoning_only_intent_still_reprompts_and_uses_a_tool(self):
|
||||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
["<think>Let me search for that.</think>"],
|
||||
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
|
||||
["Here is the answer."],
|
||||
],
|
||||
exec_results = ["result"],
|
||||
nudge_tool_calls = True,
|
||||
)
|
||||
|
||||
events = _collect_events(loop)
|
||||
|
||||
assert exec_fn.calls == [("web_search", {"query": "cats"})]
|
||||
contents = [e["text"] for e in events if e["type"] == "content"]
|
||||
assert contents[-1] == "Here is the answer."
|
||||
|
||||
def test_prefilled_no_close_reasoning_intent_still_reprompts(self):
|
||||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
["I need more context.<think>Let me search for that."],
|
||||
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
|
||||
["Here is the answer."],
|
||||
],
|
||||
exec_results = ["result"],
|
||||
nudge_tool_calls = True,
|
||||
reasoning_prefilled = True,
|
||||
)
|
||||
|
||||
events = _collect_events(loop)
|
||||
|
||||
assert exec_fn.calls == [("web_search", {"query": "cats"})]
|
||||
contents = [e["text"] for e in events if e["type"] == "content"]
|
||||
assert contents[-1] == "Here is the answer."
|
||||
|
||||
def test_prefilled_reasoning_prefix_is_kept_for_reasoning_only_reprompt(self):
|
||||
loop, exec_fn = _make_loop(
|
||||
turns = [
|
||||
["Let me search for that.</think><think>checking details</think>"],
|
||||
['<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'],
|
||||
["Here is the answer."],
|
||||
],
|
||||
exec_results = ["result"],
|
||||
nudge_tool_calls = True,
|
||||
reasoning_prefilled = True,
|
||||
)
|
||||
|
||||
events = _collect_events(loop)
|
||||
|
||||
assert exec_fn.calls == [("web_search", {"query": "cats"})]
|
||||
contents = [e["text"] for e in events if e["type"] == "content"]
|
||||
assert contents[-1] == "Here is the answer."
|
||||
|
||||
def test_reprompt_history_uses_visible_intent_text(self):
|
||||
captured: list[list[dict]] = []
|
||||
|
||||
def _gen(messages, active_tools = None):
|
||||
captured.append([dict(message) for message in messages])
|
||||
if len(captured) == 1:
|
||||
yield "<think>private planning details</think>Let me search for that."
|
||||
elif len(captured) == 2:
|
||||
yield '<tool_call>{"name":"web_search","arguments":{"query":"cats"}}</tool_call>'
|
||||
else:
|
||||
yield "Here is the answer."
|
||||
|
||||
exec_fn = FakeExecuteTool(["result"])
|
||||
events = _collect_events(
|
||||
run_safetensors_tool_loop(
|
||||
single_turn = _gen,
|
||||
messages = [{"role": "user", "content": "find cats"}],
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}],
|
||||
execute_tool = exec_fn,
|
||||
nudge_tool_calls = True,
|
||||
)
|
||||
)
|
||||
|
||||
assert exec_fn.calls == [("web_search", {"query": "cats"})]
|
||||
assert captured[1][1] == {"role": "assistant", "content": "Let me search for that."}
|
||||
contents = [e["text"] for e in events if e["type"] == "content"]
|
||||
assert contents[-1] == "Here is the answer."
|
||||
|
||||
def test_intent_signal_triggers_reprompt(self):
|
||||
# Turn 1: intent signal, no tool call.
|
||||
# Turn 2 (re-prompt): proper tool call -> executes.
|
||||
|
|
|
|||
|
|
@ -177,6 +177,15 @@ def _sse_objects(chunks):
|
|||
# ── Non-streaming ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_non_reasoning_backend_keeps_literal_think_tags(monkeypatch):
|
||||
backend = _ScriptedBackend(_fixed("show <think>example</think> tags"))
|
||||
response = _call(_request(stream = False), monkeypatch, backend, supports_tools = False)
|
||||
|
||||
message = _json_body(response)["choices"][0]["message"]
|
||||
assert message["content"] == "show <think>example</think> tags"
|
||||
assert message["reasoning_content"] is None
|
||||
|
||||
|
||||
def test_xml_healed_to_tool_calls_non_streaming(monkeypatch):
|
||||
backend = _ScriptedBackend(_fixed(_CALL_XML))
|
||||
payload = _request(tools = [LOOKUP_TOOL], stream = False)
|
||||
|
|
@ -485,6 +494,52 @@ def test_streaming_no_tools_verbatim(monkeypatch):
|
|||
assert finishes == ["stop"]
|
||||
|
||||
|
||||
def test_streaming_gen_stream_error_is_not_model_text(monkeypatch):
|
||||
from core.inference.orchestrator import GenStreamError
|
||||
|
||||
class _ErrorAfterPartial(_ScriptedBackend):
|
||||
def __init__(self):
|
||||
super().__init__(_fixed())
|
||||
|
||||
def generate_chat_response(self, **_kwargs):
|
||||
yield "<think>partial"
|
||||
yield GenStreamError("Error: /tmp/secret traceback")
|
||||
|
||||
backend = _ErrorAfterPartial()
|
||||
payload = _request(stream = True)
|
||||
response = _call(payload, monkeypatch, backend, supports_tools = False)
|
||||
chunks = _collect_sse(response)
|
||||
objs = _sse_objects(chunks)
|
||||
|
||||
deltas = [o.get("choices", [{}])[0].get("delta", {}) for o in objs if o.get("choices")]
|
||||
assert any("partial" in json.dumps(delta) for delta in deltas)
|
||||
assert not any("/tmp/secret" in json.dumps(delta) for delta in deltas)
|
||||
errors = [o["error"]["message"] for o in objs if "error" in o]
|
||||
assert errors == ["An internal error occurred."]
|
||||
assert any(
|
||||
"data: [DONE]" in (chunk.decode() if isinstance(chunk, bytes) else chunk)
|
||||
for chunk in chunks
|
||||
)
|
||||
|
||||
|
||||
def test_server_tool_streaming_invalid_event_is_error(monkeypatch):
|
||||
class _InvalidEventBackend(_ScriptedBackend):
|
||||
def __init__(self):
|
||||
super().__init__(_fixed())
|
||||
|
||||
def generate_chat_completion_with_tools(self, **_kwargs):
|
||||
yield {"type": "content", "text": "partial"}
|
||||
yield "not-an-event"
|
||||
|
||||
backend = _InvalidEventBackend()
|
||||
payload = _request(tools = [LOOKUP_TOOL], enable_tools = True, stream = True)
|
||||
response = _call(payload, monkeypatch, backend)
|
||||
objs = _sse_objects(_collect_sse(response))
|
||||
|
||||
errors = [o["error"]["message"] for o in objs if "error" in o]
|
||||
assert errors == ["An internal error occurred."]
|
||||
|
||||
|
||||
def test_streaming_repeated_snapshot_no_duplicate_call(monkeypatch):
|
||||
# Repeated then shrunk cumulative snapshots must not double-heal.
|
||||
backend = _ScriptedBackend(_fixed(_CALL_XML, _CALL_XML, _CALL_XML[:5], _CALL_XML))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""
|
||||
Unit tests for detect_think_prefill.
|
||||
Unit tests for local reasoning-stream helpers.
|
||||
|
||||
Reasoning templates (Qwen3.6-style) end the generation prompt with an open
|
||||
``<think>\\n`` so the model starts reasoning immediately. skip_prompt
|
||||
|
|
@ -16,7 +16,13 @@ import sys
|
|||
_backend = os.path.join(os.path.dirname(__file__), "..")
|
||||
sys.path.insert(0, _backend)
|
||||
|
||||
from core.inference.chat_template_helpers import detect_think_prefill
|
||||
from core.inference.chat_template_helpers import (
|
||||
ReasoningChannelNormalizer,
|
||||
detect_reasoning_channel_markers,
|
||||
detect_reasoning_channel_markers_from_model_info,
|
||||
detect_think_prefill,
|
||||
render_with_native_template_fallback,
|
||||
)
|
||||
|
||||
|
||||
QWEN_PROMPT = "<|im_start|>user\nHi!<|im_end|>\n<|im_start|>assistant\n"
|
||||
|
|
@ -87,3 +93,170 @@ def test_guard_emits_when_think_not_special():
|
|||
def test_guard_default_and_empty_keep_emitting():
|
||||
assert detect_think_prefill(QWEN_PROMPT + "<think>\n", None) == "<think>\n"
|
||||
assert detect_think_prefill(QWEN_PROMPT + "<think>\n", []) == "<think>\n"
|
||||
|
||||
|
||||
def test_gemma_channel_detection_uses_active_template_not_token_metadata():
|
||||
class TemplateTokenizer:
|
||||
chat_template = {"default": "...<|channel>thought\\n{{ eoc_token }}"}
|
||||
|
||||
class NamedTemplateTokenizer:
|
||||
chat_template = {
|
||||
"default": "plain assistant template",
|
||||
"tool_use": "...<|channel>thought\\n{{ eoc_token }}",
|
||||
}
|
||||
|
||||
class TokenMetadataOnly:
|
||||
chat_template = None
|
||||
soc_token = "<|channel>"
|
||||
eoc_token = "<channel|>"
|
||||
|
||||
class NamedTemplateProcessor:
|
||||
chat_template = {
|
||||
"default": "plain processor default",
|
||||
"tool_use": "<|channel>thought\nprocessor tool template<channel|>",
|
||||
}
|
||||
tokenizer = TokenMetadataOnly()
|
||||
|
||||
def apply_chat_template(self, *_args, **_kwargs):
|
||||
raise NotImplementedError
|
||||
|
||||
expected = ("<|channel>thought", "<channel|>")
|
||||
assert detect_reasoning_channel_markers(TemplateTokenizer()) == expected
|
||||
assert detect_reasoning_channel_markers(NamedTemplateTokenizer()) is None
|
||||
assert (
|
||||
detect_reasoning_channel_markers(
|
||||
NamedTemplateTokenizer(), tools = [{"function": {"name": "web_search"}}]
|
||||
)
|
||||
== expected
|
||||
)
|
||||
assert detect_reasoning_channel_markers(NamedTemplateTokenizer(), tools = []) is None
|
||||
assert (
|
||||
detect_reasoning_channel_markers(
|
||||
NamedTemplateProcessor(), tools = [{"function": {"name": "web_search"}}]
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert detect_reasoning_channel_markers(TokenMetadataOnly()) is None
|
||||
|
||||
|
||||
def test_gemma_channel_detection_tries_no_argument_getter_fallback():
|
||||
class FallbackTokenizer:
|
||||
chat_template = "plain fallback template"
|
||||
|
||||
def get_chat_template(self, **kwargs):
|
||||
if kwargs:
|
||||
raise ValueError("tools are not supported")
|
||||
return "...<|channel>thought\n<channel|>"
|
||||
|
||||
assert detect_reasoning_channel_markers(
|
||||
FallbackTokenizer(), tools = [{"function": {"name": "web_search"}}]
|
||||
) == ("<|channel>thought", "<channel|>")
|
||||
|
||||
|
||||
def test_native_template_fallback_returns_selected_reasoning_metadata():
|
||||
from types import SimpleNamespace
|
||||
|
||||
messages = [{"role": "user", "content": "hi"}]
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}]
|
||||
|
||||
def render(tokenizer, msgs, *, tools, **_kw):
|
||||
body = "".join(message["content"] for message in msgs)
|
||||
suffix = "|TOOLS" if tools else ""
|
||||
return body + suffix if tokenizer.chat_template == "NATIVE <|channel>thought\n" else body
|
||||
|
||||
result = render_with_native_template_fallback(
|
||||
formatted_prompt = "hi",
|
||||
tokenizer = SimpleNamespace(chat_template = "OVERRIDE"),
|
||||
model_info = {
|
||||
"native_chat_template": "NATIVE <|channel>thought\n",
|
||||
"tokenizer": SimpleNamespace(chat_template = "OVERRIDE"),
|
||||
},
|
||||
active_model_name = "gemma-test",
|
||||
messages = messages,
|
||||
tools = tools,
|
||||
apply_fn = render,
|
||||
return_metadata = True,
|
||||
)
|
||||
|
||||
assert result.prompt == "hi|TOOLS"
|
||||
assert result.reasoning_channel_markers == ("<|channel>thought", "<channel|>")
|
||||
|
||||
|
||||
def test_cached_native_template_metadata_recovers_reasoning_markers_without_tools():
|
||||
from types import SimpleNamespace
|
||||
|
||||
model_info = {"chat_template_info": {"template": "native <|channel>thought\n<channel|>"}}
|
||||
|
||||
assert detect_reasoning_channel_markers_from_model_info(
|
||||
SimpleNamespace(chat_template = "override has no native markers"),
|
||||
model_info,
|
||||
tools = None,
|
||||
) == ("<|channel>thought", "<channel|>")
|
||||
result = render_with_native_template_fallback(
|
||||
formatted_prompt = "prompt from override",
|
||||
tokenizer = SimpleNamespace(chat_template = "override has no native markers"),
|
||||
model_info = model_info,
|
||||
active_model_name = "gemma-test",
|
||||
messages = [{"role": "user", "content": "hi"}],
|
||||
tools = None,
|
||||
return_metadata = True,
|
||||
)
|
||||
assert result.prompt == "prompt from override"
|
||||
assert result.reasoning_channel_markers == ("<|channel>thought", "<channel|>")
|
||||
|
||||
|
||||
def test_cached_native_markers_do_not_describe_live_tool_template():
|
||||
from types import SimpleNamespace
|
||||
|
||||
tools = [{"type": "function", "function": {"name": "web_search"}}]
|
||||
|
||||
class LiveTokenizer:
|
||||
chat_template = "live tool template without native markers"
|
||||
|
||||
def render(_tokenizer, _messages, *, tools, **_kwargs):
|
||||
return "prompt with tools" if tools else "prompt without tools"
|
||||
|
||||
result = render_with_native_template_fallback(
|
||||
formatted_prompt = "prompt with tools",
|
||||
tokenizer = LiveTokenizer(),
|
||||
model_info = {
|
||||
"chat_template_info": {"template": "native <|channel>thought\n<channel|>"},
|
||||
"tokenizer": SimpleNamespace(),
|
||||
},
|
||||
active_model_name = "gemma-test",
|
||||
messages = [{"role": "user", "content": "hi"}],
|
||||
tools = tools,
|
||||
apply_fn = render,
|
||||
return_metadata = True,
|
||||
)
|
||||
|
||||
assert result.prompt == "prompt with tools"
|
||||
assert result.reasoning_channel_markers is None
|
||||
|
||||
|
||||
def test_gemma_channel_normalization_is_prefix_monotonic_and_preserves_tools():
|
||||
parser = ReasoningChannelNormalizer("<|channel>thought", "<channel|>")
|
||||
output = ""
|
||||
snapshots = []
|
||||
for chunk in (
|
||||
"<|chan",
|
||||
"nel>thought",
|
||||
"\nReason",
|
||||
"<chan",
|
||||
"nel|><|tool_call>web_search<tool_call|>",
|
||||
):
|
||||
delta = parser.feed(chunk)
|
||||
if delta:
|
||||
output += delta
|
||||
snapshots.append(output)
|
||||
|
||||
assert snapshots == [
|
||||
"<think>",
|
||||
"<think>Reason",
|
||||
"<think>Reason</think><|tool_call>web_search<tool_call|>",
|
||||
]
|
||||
assert snapshots[1].startswith(snapshots[0])
|
||||
compact = ReasoningChannelNormalizer("<|channel>thought", "<channel|>")
|
||||
assert compact.feed("<|channel>thought<channel|>answer") + compact.finish() == (
|
||||
"<think></think>answer"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ if _BACKEND_DIR not in sys.path:
|
|||
|
||||
from core.inference.tool_loop_controller import (
|
||||
ToolLoopController,
|
||||
append_deferred_nudges,
|
||||
canonical_tool_call_key,
|
||||
coerce_tool_arguments,
|
||||
status_for_tool,
|
||||
|
|
@ -21,6 +22,22 @@ from core.inference.tool_loop_controller import (
|
|||
)
|
||||
|
||||
|
||||
def test_append_deferred_nudges_merges_deduped_into_one_message():
|
||||
conversation = [{"role": "assistant", "tool_calls": [1]}, {"role": "tool", "content": "r"}]
|
||||
nudges = [
|
||||
{"role": "user", "content": "duplicate"},
|
||||
{"role": "user", "content": "duplicate"}, # dropped: same content
|
||||
{"role": "user", "content": "disabled foo"},
|
||||
]
|
||||
append_deferred_nudges(conversation, nudges)
|
||||
# One user message, after the results, with distinct contents joined.
|
||||
assert conversation[2:] == [{"role": "user", "content": "duplicate\n\ndisabled foo"}]
|
||||
# Empty is a no-op.
|
||||
before = list(conversation)
|
||||
append_deferred_nudges(conversation, [])
|
||||
assert conversation == before
|
||||
|
||||
|
||||
def _tool(name: str) -> dict:
|
||||
return {"type": "function", "function": {"name": name}}
|
||||
|
||||
|
|
@ -111,6 +128,10 @@ def test_successful_duplicate_is_internal_noop_and_keeps_remaining_tools():
|
|||
assert not duplicate.should_execute
|
||||
assert not duplicate.emit_visible_events
|
||||
duplicate_nudge = completion.model_message()["content"]
|
||||
assert duplicate_nudge.startswith(
|
||||
"One earlier request to call tool 'web_search' in this batch was not executed"
|
||||
)
|
||||
assert "previous tool request" not in duplicate_nudge.lower()
|
||||
assert "already completed successfully" in duplicate_nudge
|
||||
assert "different enabled tool" in duplicate_nudge
|
||||
assert completion.model_message()["role"] == "user"
|
||||
|
|
@ -165,7 +186,12 @@ def test_empty_enabled_tool_list_blocks_all_tool_calls():
|
|||
assert decision.action == "disabled"
|
||||
assert not decision.emit_visible_events
|
||||
assert completion.model_message()["role"] == "user"
|
||||
assert "not enabled" in completion.model_message()["content"]
|
||||
disabled_nudge = completion.model_message()["content"]
|
||||
assert disabled_nudge.startswith(
|
||||
"One earlier request to call tool 'web_search' in this batch was not executed"
|
||||
)
|
||||
assert "previous tool request" not in disabled_nudge.lower()
|
||||
assert "not enabled" in disabled_nudge
|
||||
assert controller.force_final_answer
|
||||
assert controller.active_tools() == []
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,18 @@ def _fetch_with(monkeypatch, body: bytes, content_type: str | None) -> str:
|
|||
return tools._fetch_page_text("https://example.com/thing", timeout = 5)
|
||||
|
||||
|
||||
def _pdf_bytes(*page_texts: str) -> bytes:
|
||||
pymupdf = pytest.importorskip("pymupdf")
|
||||
doc = pymupdf.open()
|
||||
for text in page_texts:
|
||||
page = doc.new_page()
|
||||
if text:
|
||||
page.insert_textbox(pymupdf.Rect(40, 40, 550, 750), text, fontsize = 11)
|
||||
data = doc.tobytes()
|
||||
doc.close()
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content_type,expected",
|
||||
[
|
||||
|
|
@ -90,10 +102,119 @@ def test_is_text_candidate_content_type(content_type, expected):
|
|||
assert tools._is_text_candidate_content_type(content_type) is expected
|
||||
|
||||
|
||||
def test_pdf_rejected_by_content_type(monkeypatch):
|
||||
out = _fetch_with(monkeypatch, b"%PDF-1.7\n\xff\xd8\xff\x00\x89PNG" * 200, "application/pdf")
|
||||
assert "<EFBFBD>" not in out
|
||||
assert "non-text content" in out and "application/pdf" in out
|
||||
@pytest.mark.parametrize(
|
||||
"content_type",
|
||||
["application/pdf", "application/octet-stream", "text/html", "text/plain", None],
|
||||
)
|
||||
def test_pdf_text_extracted(monkeypatch, content_type):
|
||||
out = _fetch_with(
|
||||
monkeypatch,
|
||||
_pdf_bytes("First page marker", "Second page marker"),
|
||||
content_type,
|
||||
)
|
||||
assert "## Page 1\n\nFirst page marker" in out
|
||||
assert "## Page 2" in out and "Second page marker" in out
|
||||
assert "binary content" not in out and "non-text content" not in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content_type", ["application/pdf", "text/plain"])
|
||||
def test_malformed_pdf_returns_safe_placeholder(monkeypatch, content_type):
|
||||
out = _fetch_with(monkeypatch, b"%PDF-1.7\nnot a complete PDF", content_type)
|
||||
assert out == "(PDF content could not be read as text)"
|
||||
|
||||
|
||||
def test_pdf_without_text_layer_reported(monkeypatch):
|
||||
out = _fetch_with(monkeypatch, _pdf_bytes(""), "application/pdf")
|
||||
assert out == "(PDF contains no extractable text)"
|
||||
|
||||
|
||||
def test_encrypted_pdf_returns_safe_placeholder(monkeypatch):
|
||||
pymupdf = pytest.importorskip("pymupdf")
|
||||
doc = pymupdf.open()
|
||||
doc.new_page().insert_text((40, 40), "private text")
|
||||
data = doc.tobytes(
|
||||
encryption = pymupdf.PDF_ENCRYPT_AES_256,
|
||||
owner_pw = "owner",
|
||||
user_pw = "secret",
|
||||
)
|
||||
doc.close()
|
||||
out = _fetch_with(monkeypatch, data, "application/pdf")
|
||||
assert out == "(PDF content could not be read as text)"
|
||||
|
||||
|
||||
def test_pdf_download_limit_enforced(monkeypatch):
|
||||
monkeypatch.setattr(tools, "_MAX_PDF_FETCH_BYTES", 256)
|
||||
out = _fetch_with(monkeypatch, _pdf_bytes("Readable but oversized"), "application/pdf")
|
||||
assert out == "(PDF content exceeds the download limit; not readable as text)"
|
||||
|
||||
|
||||
def test_mislabeled_pdf_is_read_past_text_download_cap(monkeypatch):
|
||||
body = _pdf_bytes("Cross-reference data was fetched")
|
||||
monkeypatch.setattr(tools, "_MAX_FETCH_BYTES", 128)
|
||||
monkeypatch.setattr(tools, "_MAX_PDF_FETCH_BYTES", len(body) + 100)
|
||||
out = _fetch_with(monkeypatch, body, "text/plain")
|
||||
assert "Cross-reference data was fetched" in out
|
||||
|
||||
|
||||
def test_pdf_extraction_caps_pages_and_intermediate_text(monkeypatch):
|
||||
from core.rag.parsers import Page
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_parse(data, *, max_pages = None):
|
||||
seen["max_pages"] = max_pages
|
||||
pages = [Page(text = "x" * 1000, page_number = i, char_count = 1000) for i in range(1, 51)]
|
||||
return pages, 60 # document actually has more pages than the cap
|
||||
|
||||
monkeypatch.setattr("core.rag.parsers.parse_pdf_bytes", fake_parse)
|
||||
text = tools._extract_pdf_text(b"unused")
|
||||
assert seen["max_pages"] == tools._MAX_WEB_PDF_PAGES
|
||||
assert len(text) <= tools._MAX_PAGE_CHARS
|
||||
assert "text limited to 16,000 characters" in text
|
||||
assert "page processing capped at 50 pages" in text
|
||||
|
||||
|
||||
def test_pdf_exactly_at_page_cap_not_marked_capped(monkeypatch):
|
||||
from core.rag.parsers import Page
|
||||
|
||||
# Exactly _MAX_WEB_PDF_PAGES pages are fully read, so no "capped" marker.
|
||||
monkeypatch.setattr(
|
||||
"core.rag.parsers.parse_pdf_bytes",
|
||||
lambda data, *, max_pages = None: (
|
||||
[Page(text = "short", page_number = i, char_count = 5) for i in range(1, 51)],
|
||||
50,
|
||||
),
|
||||
)
|
||||
text = tools._extract_pdf_text(b"unused")
|
||||
assert "page processing capped" not in text
|
||||
assert "## Page 50\n\nshort" in text
|
||||
|
||||
|
||||
def test_pdf_page_cap_does_not_claim_later_pages_are_textless(monkeypatch):
|
||||
from core.rag.parsers import Page
|
||||
monkeypatch.setattr(
|
||||
"core.rag.parsers.parse_pdf_bytes",
|
||||
lambda data, *, max_pages = None: (
|
||||
[Page(text = "", page_number = i, char_count = 0) for i in range(1, 51)],
|
||||
60,
|
||||
),
|
||||
)
|
||||
assert tools._extract_pdf_text(b"unused") == (
|
||||
"(PDF contains no extractable text in the first 50 pages)"
|
||||
)
|
||||
|
||||
|
||||
def test_pdf_result_discarded_after_fetch_deadline(monkeypatch):
|
||||
clock = {"time": 1000.0}
|
||||
monkeypatch.setattr(tools.time, "monotonic", lambda: clock["time"])
|
||||
|
||||
def slow_extract(data):
|
||||
clock["time"] += 10.0
|
||||
return "late PDF text"
|
||||
|
||||
monkeypatch.setattr(tools, "_extract_pdf_text", slow_extract)
|
||||
out = _fetch_with(monkeypatch, _pdf_bytes("Readable text"), "application/pdf")
|
||||
assert out == "Failed to fetch URL: timed out."
|
||||
|
||||
|
||||
def test_text_octet_stream_kept_after_sniffing(monkeypatch):
|
||||
|
|
@ -154,7 +275,6 @@ def test_valid_utf8_binary_caught_by_control_chars(monkeypatch):
|
|||
@pytest.mark.parametrize(
|
||||
"magic",
|
||||
[
|
||||
b"%PDF-",
|
||||
b"PK\x03\x04",
|
||||
b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",
|
||||
b"\x1f\x8b",
|
||||
|
|
@ -180,8 +300,8 @@ def test_text_labeled_binary_caught_by_magic(monkeypatch, magic):
|
|||
b"\t\xef\xbb\xbf ",
|
||||
],
|
||||
)
|
||||
def test_pdf_magic_after_harmless_prefix(monkeypatch, prefix):
|
||||
body = prefix + b"%PDF-1.7\n" + b"1 0 obj<</Type/Catalog>>endobj\n" * 100
|
||||
def test_binary_magic_after_harmless_prefix(monkeypatch, prefix):
|
||||
body = prefix + b"\x1f\x8b" + b" printable text-heavy body" * 100
|
||||
out = _fetch_with(monkeypatch, body, "text/plain")
|
||||
assert "binary content" in out
|
||||
|
||||
|
|
@ -243,10 +363,10 @@ def test_html_page_unaffected(monkeypatch):
|
|||
|
||||
def test_content_type_sanitized_in_message(monkeypatch):
|
||||
# Do not echo obs-folded header content into the model response.
|
||||
out = _fetch_with(monkeypatch, b"\x00\x01\x02" * 500, "application/pdf\r\n data: injected")
|
||||
out = _fetch_with(monkeypatch, b"PK\x03\x04" * 500, "application/zip\r\n data: injected")
|
||||
assert "\n" not in out and "\r" not in out
|
||||
assert "injected" not in out
|
||||
assert "application/pdf" in out
|
||||
assert "application/zip" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
|
|||
|
|
@ -49,6 +49,11 @@ logger = get_logger(__name__)
|
|||
# and spawn workers copy os.environ. setdefault so an explicit user override wins.
|
||||
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
|
||||
|
||||
# Studio workers can import MLX without importing unsloth first, so mirror the
|
||||
# package bootstrap here. Keep an explicit user value authoritative.
|
||||
if platform.system() == "Darwin" and platform.machine() == "arm64":
|
||||
os.environ.setdefault("AGX_RELAX_CDM_CTXSTORE_TIMEOUT", "1")
|
||||
|
||||
|
||||
# ========== Device Enum ==========
|
||||
|
||||
|
|
|
|||
|
|
@ -1551,7 +1551,7 @@ export function AppSidebar() {
|
|||
<SidebarMenuButton
|
||||
size="lg"
|
||||
aria-label={t("shell.accountMenu", { name: displayTitle })}
|
||||
className="sidebar-nav-btn !h-[44px] -my-[3px] gap-[9px] px-2 py-[3px] rounded-[14px] group-data-[collapsible=icon]:!size-[34px] group-data-[collapsible=icon]:!rounded-full group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:justify-center"
|
||||
className="sidebar-nav-btn !h-[44px] -my-[3px] gap-[9px] pl-2 pr-[45px] py-[3px] rounded-[14px] group-data-[collapsible=icon]:!size-[34px] group-data-[collapsible=icon]:!rounded-full group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:mx-auto group-data-[collapsible=icon]:justify-center"
|
||||
>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<UserAvatar
|
||||
|
|
@ -1561,21 +1561,12 @@ export function AppSidebar() {
|
|||
className="!size-[32px] group-data-[collapsible=icon]:!rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
|
||||
{/* min-w-0 so long names truncate instead of overflowing;
|
||||
pr on the button reserves room for the settings cog */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-px leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="truncate font-heading text-[13.5px] tracking-[0.025em] dark:tracking-[0.04em] font-semibold text-nav-fg">{displayTitle}</span>
|
||||
<span className="truncate text-[11.5px] tracking-nav text-muted-foreground">Unsloth</span>
|
||||
</div>
|
||||
{/* settings cog (replaces the up/down chevron) */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-auto flex size-[32px] shrink-0 items-center justify-center text-muted-foreground group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Settings02Icon}
|
||||
strokeWidth={1.5}
|
||||
className="!size-[18px]"
|
||||
/>
|
||||
</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
|
|
@ -1690,6 +1681,20 @@ export function AppSidebar() {
|
|||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* settings cog; sibling of the trigger (buttons cannot nest),
|
||||
overlaid on the row's right edge, opens settings directly */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("shell.navigation.settings")}
|
||||
onClick={() => useSettingsDialogStore.getState().openDialog()}
|
||||
className="absolute right-2 top-1/2 flex size-[32px] -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-black/10 hover:text-foreground dark:hover:bg-white/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Settings02Icon}
|
||||
strokeWidth={1.5}
|
||||
className="!size-[18px]"
|
||||
/>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
|
|
|||
|
|
@ -1326,7 +1326,9 @@ const ThreadWelcome: FC<{
|
|||
|
||||
useEffect(() => {
|
||||
// Prefer the nickname; otherwise first name only. Blank falls back to none.
|
||||
const name = nickname.trim() || (displayName.trim().split(/\s+/)[0] ?? "");
|
||||
const raw = nickname.trim() || (displayName.trim().split(/\s+/)[0] ?? "");
|
||||
// Cap very long names so the greeting stays on one line.
|
||||
const name = raw.length > 20 ? `${raw.slice(0, 20)}…` : raw;
|
||||
setWelcome(buildWelcome(new Date().getHours(), name));
|
||||
}, [displayName, nickname]);
|
||||
|
||||
|
|
@ -2707,6 +2709,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
|
|||
const setCodeToolsEnabled = useChatRuntimeStore((s) => s.setCodeToolsEnabled);
|
||||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
|
||||
const showCanvasMenuItem = useChatRuntimeStore((s) => s.showCanvasMenuItem);
|
||||
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
|
||||
const setMcpEnabledForChat = useChatRuntimeStore(
|
||||
(s) => s.setMcpEnabledForChat,
|
||||
|
|
@ -2957,7 +2960,8 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
|
|||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
),
|
||||
canvas: (
|
||||
// Hidden by default; enabled from Settings > Chat > Canvas.
|
||||
canvas: showCanvasMenuItem ? (
|
||||
<DropdownMenuItem
|
||||
className={artifactsEnabled ? "text-primary font-medium" : undefined}
|
||||
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
|
||||
|
|
@ -2968,7 +2972,7 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({
|
|||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
) : null,
|
||||
bypassPermissions: <BypassPermissionsMenuItem />,
|
||||
projects: (
|
||||
<DropdownMenuSub>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Tick02Icon } from "@/lib/tick-icon";
|
||||
import { ChevronRightStandardIcon } from "@/lib/chevron-icons";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
|
|
@ -206,6 +207,14 @@ function DropdownMenuShortcut({
|
|||
);
|
||||
}
|
||||
|
||||
function assignRef<T>(ref: React.Ref<T> | undefined, value: T | null) {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
} else if (ref) {
|
||||
ref.current = value;
|
||||
}
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
|
|
@ -242,17 +251,63 @@ function DropdownMenuSubTrigger({
|
|||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
sideOffset,
|
||||
style,
|
||||
ref,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
const isMobile = useIsMobile();
|
||||
const [contentWidth, setContentWidth] = React.useState(0);
|
||||
const resizeObserverRef = React.useRef<ResizeObserver | null>(null);
|
||||
const composedRef = React.useCallback(
|
||||
(
|
||||
element: React.ComponentRef<
|
||||
typeof DropdownMenuPrimitive.SubContent
|
||||
> | null,
|
||||
) => {
|
||||
resizeObserverRef.current?.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
assignRef(ref, element);
|
||||
if (!element) return;
|
||||
|
||||
const updateContentWidth = () => {
|
||||
setContentWidth(element.offsetWidth);
|
||||
};
|
||||
updateContentWidth();
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserverRef.current = new ResizeObserver(updateContentWidth);
|
||||
resizeObserverRef.current.observe(element);
|
||||
}
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
resizeObserverRef.current?.disconnect();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const compactSideOffset =
|
||||
isMobile && contentWidth > 0 ? -contentWidth : sideOffset;
|
||||
return (
|
||||
// Portaled like DropdownMenuContent: rendered inline, the fixed popper
|
||||
// wrapper is a descendant of the parent menu's scroll container, so any
|
||||
// transform there turns on overflow clipping and hides the submenu.
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={composedRef}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
sideOffset={compactSideOffset}
|
||||
style={{
|
||||
...style,
|
||||
visibility:
|
||||
isMobile && contentWidth === 0 ? "hidden" : style?.visibility,
|
||||
}}
|
||||
className={cn(
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-36 rounded-lg p-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
|
||||
"data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 bg-popover text-popover-foreground min-w-36 max-w-[calc(100vw-2rem)] rounded-lg p-1 duration-100 z-50 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -613,6 +613,7 @@ export function SharedComposer({
|
|||
);
|
||||
const artifactsEnabled = useChatRuntimeStore((s) => s.artifactsEnabled);
|
||||
const setArtifactsEnabled = useChatRuntimeStore((s) => s.setArtifactsEnabled);
|
||||
const showCanvasMenuItem = useChatRuntimeStore((s) => s.showCanvasMenuItem);
|
||||
const permissionMode = useChatRuntimeStore((s) => s.permissionMode);
|
||||
const mcpEnabledForChat = useChatRuntimeStore((s) => s.mcpEnabledForChat);
|
||||
const setMcpEnabledForChat = useChatRuntimeStore(
|
||||
|
|
@ -1411,7 +1412,8 @@ export function SharedComposer({
|
|||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
),
|
||||
canvas: (
|
||||
// Hidden by default; enabled from Settings > Chat > Canvas.
|
||||
canvas: showCanvasMenuItem ? (
|
||||
<DropdownMenuItem
|
||||
className={artifactsEnabled ? "text-primary font-medium" : undefined}
|
||||
onSelect={() => setArtifactsEnabled(!artifactsEnabled)}
|
||||
|
|
@ -1422,7 +1424,7 @@ export function SharedComposer({
|
|||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="ml-auto" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
) : null,
|
||||
bypassPermissions: <BypassPermissionsMenuItem />,
|
||||
projects: (
|
||||
<DropdownMenuSub>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import type { RememberedLoadSettings } from "@/components/assistant-ui/model-selector/remembered-load-settings";
|
||||
import { cancelStagedModelDownload } from "@/features/hub";
|
||||
import {
|
||||
cancelStagedModelDownload,
|
||||
mirrorHfTokenInto,
|
||||
useHfTokenStore,
|
||||
} from "@/features/hub";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { create } from "zustand";
|
||||
import { isExternalModelId, parseExternalModelId } from "../external-providers";
|
||||
|
|
@ -23,14 +27,15 @@ import {
|
|||
savePersistedChatSettingsPatch,
|
||||
} from "../utils/chat-settings-storage";
|
||||
import { useExternalProvidersStore } from "./external-providers-store";
|
||||
import { PLUS_MENU_PINS_STORAGE_KEY } from "./plus-menu-prefs-store";
|
||||
|
||||
const HF_TOKEN_KEY = "unsloth_hf_token";
|
||||
const HF_TOKEN_CHANGED_EVENT = "unsloth:hf-token-changed";
|
||||
export const CHAT_REASONING_ENABLED_KEY = "unsloth_chat_reasoning_enabled";
|
||||
export const CHAT_TOOLS_ENABLED_KEY = "unsloth_chat_tools_enabled";
|
||||
export const CHAT_CODE_TOOLS_ENABLED_KEY = "unsloth_chat_code_tools_enabled";
|
||||
export const CHAT_IMAGE_TOOLS_ENABLED_KEY = "unsloth_chat_image_tools_enabled";
|
||||
export const CHAT_ARTIFACTS_ENABLED_KEY = "unsloth_chat_artifacts_enabled";
|
||||
export const CHAT_SHOW_CANVAS_MENU_ITEM_KEY =
|
||||
"unsloth_chat_show_canvas_menu_item";
|
||||
export const CHAT_COLLAPSE_HTML_ARTIFACTS_KEY =
|
||||
"unsloth_chat_collapse_html_artifacts";
|
||||
export const CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY =
|
||||
|
|
@ -357,6 +362,24 @@ function saveBool(key: string, value: boolean): void {
|
|||
}
|
||||
}
|
||||
|
||||
// The visibility flag shipped after the menu pins, so when it is absent,
|
||||
// profiles that had explicitly pinned Canvas keep it visible.
|
||||
function loadShowCanvasMenuItem(): boolean {
|
||||
const stored = loadOptionalBool(CHAT_SHOW_CANVAS_MENU_ITEM_KEY);
|
||||
if (stored !== null) return stored;
|
||||
if (!canUseStorage()) return false;
|
||||
try {
|
||||
const raw = localStorage.getItem(PLUS_MENU_PINS_STORAGE_KEY);
|
||||
if (raw === null) return false;
|
||||
const parsed = JSON.parse(raw) as {
|
||||
state?: { pins?: { canvas?: boolean } };
|
||||
};
|
||||
return parsed.state?.pins?.canvas === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "full" is intentionally not restorable: it disables the sandbox and every
|
||||
* confirmation gate, so it must be re-enabled (through the warning dialog)
|
||||
|
|
@ -474,17 +497,6 @@ export function saveSpeculativeType(value: string | null): void {
|
|||
}
|
||||
}
|
||||
|
||||
function notifyHfTokenChanged(value: string): void {
|
||||
if (!canUseStorage()) return;
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(HF_TOKEN_CHANGED_EVENT, { detail: value }),
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** A local model staged for a deferred load (see `pendingSelection`). Shape is
|
||||
* a subset of the load hook's `SelectedModelInput`, structurally assignable. */
|
||||
export type PendingModelSelection = {
|
||||
|
|
@ -642,6 +654,8 @@ type ChatRuntimeStore = {
|
|||
codeToolsEnabled: boolean;
|
||||
imageToolsEnabled: boolean;
|
||||
artifactsEnabled: boolean;
|
||||
// Whether the Canvas toggle is offered in the composer + menu (hidden by default).
|
||||
showCanvasMenuItem: boolean;
|
||||
collapseHtmlArtifacts: boolean;
|
||||
allowArtifactNetworkAccess: boolean;
|
||||
mcpEnabledForChat: boolean;
|
||||
|
|
@ -818,6 +832,7 @@ type ChatRuntimeStore = {
|
|||
enabled: boolean,
|
||||
options?: { persist?: boolean },
|
||||
) => void;
|
||||
setShowCanvasMenuItem: (enabled: boolean) => void;
|
||||
setCollapseHtmlArtifacts: (enabled: boolean) => void;
|
||||
setAllowArtifactNetworkAccess: (enabled: boolean) => void;
|
||||
setMcpEnabledForChat: (enabled: boolean) => void;
|
||||
|
|
@ -1120,7 +1135,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
runningByThreadId: {},
|
||||
cancelByThreadId: {},
|
||||
autoTitle: false,
|
||||
hfToken: loadString(HF_TOKEN_KEY, ""),
|
||||
hfToken: useHfTokenStore.getState().token,
|
||||
modelsError: null,
|
||||
lastModelLoadError: null,
|
||||
activeGgufVariant: null,
|
||||
|
|
@ -1147,6 +1162,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
codeToolsEnabled: loadBool(CHAT_CODE_TOOLS_ENABLED_KEY, false),
|
||||
imageToolsEnabled: loadBool(CHAT_IMAGE_TOOLS_ENABLED_KEY, false),
|
||||
artifactsEnabled: loadBool(CHAT_ARTIFACTS_ENABLED_KEY, false),
|
||||
showCanvasMenuItem: loadShowCanvasMenuItem(),
|
||||
collapseHtmlArtifacts: loadBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, false),
|
||||
allowArtifactNetworkAccess: loadBool(
|
||||
CHAT_ALLOW_ARTIFACT_NETWORK_ACCESS_KEY,
|
||||
|
|
@ -1324,11 +1340,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
setScalarSettingVersion("autoTitle", autoTitle, state.autoTitle);
|
||||
return { autoTitle };
|
||||
}),
|
||||
setHfToken: (hfToken) => {
|
||||
saveString(HF_TOKEN_KEY, hfToken);
|
||||
set({ hfToken });
|
||||
notifyHfTokenChanged(hfToken);
|
||||
},
|
||||
setHfToken: (hfToken) => useHfTokenStore.getState().setToken(hfToken),
|
||||
setModelsError: (modelsError) => set({ modelsError }),
|
||||
setLastModelLoadError: (lastModelLoadError) => set({ lastModelLoadError }),
|
||||
setCheckpoint: (modelId, ggufVariant) =>
|
||||
|
|
@ -1504,6 +1516,11 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
}
|
||||
return { artifactsEnabled };
|
||||
}),
|
||||
setShowCanvasMenuItem: (showCanvasMenuItem) =>
|
||||
set(() => {
|
||||
saveBool(CHAT_SHOW_CANVAS_MENU_ITEM_KEY, showCanvasMenuItem);
|
||||
return { showCanvasMenuItem };
|
||||
}),
|
||||
setCollapseHtmlArtifacts: (collapseHtmlArtifacts) =>
|
||||
set((state) => {
|
||||
saveBool(CHAT_COLLAPSE_HTML_ARTIFACTS_KEY, collapseHtmlArtifacts);
|
||||
|
|
@ -1807,6 +1824,12 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
|
|||
setContextUsage: (contextUsage) => set({ contextUsage }),
|
||||
}));
|
||||
|
||||
// Mirror token edits made through the shared store (e.g. Studio's field).
|
||||
const unsubscribeHfTokenMirror = mirrorHfTokenInto(useChatRuntimeStore);
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(unsubscribeHfTokenMirror);
|
||||
}
|
||||
|
||||
export function resolveSpeculativeSettingsForLoad({
|
||||
usePersistedPreference = false,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ const DEFAULT_PINS: Record<PlusMenuItemId, boolean> = {
|
|||
bypassPermissions: false,
|
||||
};
|
||||
|
||||
export const PLUS_MENU_PINS_STORAGE_KEY = "unsloth_plus_menu_pins";
|
||||
|
||||
export interface PlusMenuPrefsState {
|
||||
pins: Record<PlusMenuItemId, boolean>;
|
||||
setPin: (id: PlusMenuItemId, value: boolean) => void;
|
||||
|
|
@ -72,7 +74,7 @@ export const usePlusMenuPrefsStore = create<PlusMenuPrefsState>()(
|
|||
})),
|
||||
}),
|
||||
{
|
||||
name: "unsloth_plus_menu_pins",
|
||||
name: PLUS_MENU_PINS_STORAGE_KEY,
|
||||
// Backfill any ids added in a later release so persisted state from an
|
||||
// older version still resolves every menu item.
|
||||
merge: (persisted, current) => {
|
||||
|
|
|
|||
|
|
@ -2,3 +2,8 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
export { cancelStagedModelDownload } from "./download-manager";
|
||||
export {
|
||||
getHfToken,
|
||||
mirrorHfTokenInto,
|
||||
useHfTokenStore,
|
||||
} from "./stores/hf-token-store";
|
||||
|
|
|
|||
|
|
@ -5,11 +5,9 @@ import { create } from "zustand";
|
|||
import { bumpInventoryVersion } from "./inventory-events";
|
||||
|
||||
const HF_TOKEN_KEY = "unsloth_hf_token";
|
||||
const HF_TOKEN_CHANGED_EVENT = "unsloth:hf-token-changed";
|
||||
const LEGACY_TRAINING_KEY = "unsloth_training_config_v1";
|
||||
let storageSyncStarted = false;
|
||||
let storageSyncListener: ((event: StorageEvent) => void) | null = null;
|
||||
let tokenChangedListener: ((event: Event) => void) | null = null;
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
|
|
@ -63,10 +61,6 @@ function stopStorageSync(): void {
|
|||
window.removeEventListener("storage", storageSyncListener);
|
||||
storageSyncListener = null;
|
||||
}
|
||||
if (tokenChangedListener !== null) {
|
||||
window.removeEventListener(HF_TOKEN_CHANGED_EVENT, tokenChangedListener);
|
||||
tokenChangedListener = null;
|
||||
}
|
||||
storageSyncStarted = false;
|
||||
}
|
||||
|
||||
|
|
@ -100,10 +94,6 @@ export const useHfTokenStore = create<HfTokenStore>((set) => {
|
|||
applyToken(event.newValue ?? "", false);
|
||||
};
|
||||
window.addEventListener("storage", storageSyncListener);
|
||||
tokenChangedListener = (event) => {
|
||||
applyToken((event as CustomEvent<string>).detail ?? "", false);
|
||||
};
|
||||
window.addEventListener(HF_TOKEN_CHANGED_EVENT, tokenChangedListener);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -117,6 +107,21 @@ export function getHfToken(): string {
|
|||
return useHfTokenStore.getState().token;
|
||||
}
|
||||
|
||||
// Keep a plain zustand store's `hfToken` field in sync with the shared token:
|
||||
// seed the current value, then mirror later edits. Returns the unsubscribe so
|
||||
// callers can wire it to HMR disposal.
|
||||
export function mirrorHfTokenInto<T extends { hfToken: string }>(store: {
|
||||
getState: () => T;
|
||||
setState: (partial: Partial<T>) => void;
|
||||
}): () => void {
|
||||
store.setState({ hfToken: getHfToken() } as Partial<T>);
|
||||
return useHfTokenStore.subscribe((state) => {
|
||||
if (store.getState().hfToken !== state.token) {
|
||||
store.setState({ hfToken: state.token } as Partial<T>);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// HF's JS client throws on a non-empty token that isn't `hf_...` instead of
|
||||
// browsing anonymously, so treat anything malformed as no token.
|
||||
export function hfApiToken(
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function MonitorEntry({
|
|||
const reply = replyText || (entry.status === "running" ? "Waiting..." : "No reply");
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-border/70 bg-background">
|
||||
<article className="min-w-0 rounded-lg border border-border/70 bg-background">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
|
|
|
|||
|
|
@ -253,7 +253,8 @@ export function SettingsDialog() {
|
|||
<DialogDescription className="sr-only">
|
||||
{t("settings.dialog.description")}
|
||||
</DialogDescription>
|
||||
<div className="flex h-full min-h-0 max-sm:flex-col">
|
||||
{/* Keep tab content from expanding the dialog grid. */}
|
||||
<div className="flex h-full min-h-0 min-w-0 w-full max-sm:flex-col">
|
||||
<aside className="font-heading flex w-[248px] shrink-0 flex-col border-r border-sidebar-border bg-muted/20 p-2 dark:border-r-0 max-sm:w-full max-sm:border-r-0 max-sm:border-b max-sm:border-sidebar-border">
|
||||
<div className="relative mx-1 mt-3 mb-2 max-sm:hidden">
|
||||
<HugeiconsIcon
|
||||
|
|
|
|||
|
|
@ -175,6 +175,12 @@ export function ChatTab() {
|
|||
const [clearing, setClearing] = useState(false);
|
||||
const autoTitle = useChatRuntimeStore((state) => state.autoTitle);
|
||||
const setAutoTitle = useChatRuntimeStore((state) => state.setAutoTitle);
|
||||
const showCanvasMenuItem = useChatRuntimeStore(
|
||||
(state) => state.showCanvasMenuItem,
|
||||
);
|
||||
const setShowCanvasMenuItem = useChatRuntimeStore(
|
||||
(state) => state.setShowCanvasMenuItem,
|
||||
);
|
||||
const collapseHtmlArtifacts = useChatRuntimeStore(
|
||||
(state) => state.collapseHtmlArtifacts,
|
||||
);
|
||||
|
|
@ -408,9 +414,16 @@ export function ChatTab() {
|
|||
>
|
||||
{PLUS_MENU_SETTINGS.map((item) => (
|
||||
<SettingsRow key={item.id} label={item.label} icon={item.icon}>
|
||||
{/* Canvas toggles menu visibility; the rest toggle pin placement. */}
|
||||
<Switch
|
||||
checked={plusPins[item.id]}
|
||||
onCheckedChange={() => togglePlusPin(item.id)}
|
||||
checked={
|
||||
item.id === "canvas" ? showCanvasMenuItem : plusPins[item.id]
|
||||
}
|
||||
onCheckedChange={
|
||||
item.id === "canvas"
|
||||
? setShowCanvasMenuItem
|
||||
: () => togglePlusPin(item.id)
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -654,9 +654,10 @@ export function GeneralTab() {
|
|||
description={t("settings.general.rag.embeddingModelDescription", {
|
||||
defaultModel: embeddingModel?.defaultEmbeddingModel ?? "",
|
||||
})}
|
||||
className="max-[360px]:flex-col max-[360px]:items-stretch max-[360px]:gap-3"
|
||||
>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col items-end gap-1 max-[360px]:w-full">
|
||||
<div className="flex items-center gap-2 max-[360px]:w-full">
|
||||
<EmbeddingModelCombobox
|
||||
value={draftEmbeddingModel}
|
||||
onChange={(next) => {
|
||||
|
|
@ -668,7 +669,7 @@ export function GeneralTab() {
|
|||
disabled={!embeddingModel}
|
||||
placeholder={embeddingModel?.defaultEmbeddingModel ?? ""}
|
||||
ariaLabel={t("settings.general.rag.embeddingModel")}
|
||||
className="w-[220px]"
|
||||
className="w-[220px] max-[360px]:min-w-0 max-[360px]:flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ import {
|
|||
// Imported directly from the store module rather than the "@/features/training"
|
||||
// barrel to avoid an import cycle (the barrel re-exports this section's siblings).
|
||||
import { hasSeparateStreamingEvalSplit } from "@/features/training/stores/training-config-store";
|
||||
import { useDebouncedValue, useHfTokenValidation } from "@/hooks";
|
||||
import { useDebouncedValue } from "@/hooks";
|
||||
import { translate, useT } from "@/i18n";
|
||||
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -398,9 +398,6 @@ export function DatasetSection() {
|
|||
enabled: pickerTab === "huggingface",
|
||||
});
|
||||
|
||||
const { error: tokenValidationError, isChecking: isCheckingToken } =
|
||||
useHfTokenValidation(hfToken);
|
||||
|
||||
const hfResultIds = useMemo(() => {
|
||||
const ids = hfResults.map((r) => r.id);
|
||||
if (dataset && !ids.includes(dataset)) {
|
||||
|
|
@ -683,11 +680,7 @@ export function DatasetSection() {
|
|||
title={t("studio.dataset.title")}
|
||||
description={t("studio.dataset.description")}
|
||||
accent="indigo"
|
||||
className={`dark:shadow-border ${
|
||||
advancedOpen || (datasetSource === "upload" && uploadedFile)
|
||||
? "min-h-studio-config-column"
|
||||
: "h-studio-config-column"
|
||||
}`}
|
||||
className="dark:shadow-border min-h-studio-config-column"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{(() => {
|
||||
|
|
@ -1009,9 +1002,9 @@ export function DatasetSection() {
|
|||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
{(tokenValidationError ?? hfSearchError) && (
|
||||
{hfSearchError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{tokenValidationError ?? hfSearchError}
|
||||
{hfSearchError}
|
||||
{" — "}
|
||||
<a
|
||||
href="https://huggingface.co/settings/tokens"
|
||||
|
|
@ -1023,11 +1016,6 @@ export function DatasetSection() {
|
|||
</a>
|
||||
</p>
|
||||
)}
|
||||
{isCheckingToken && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("studio.dataset.checkingToken")}
|
||||
</p>
|
||||
)}
|
||||
{pickerTab !== activeSourceTab && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("studio.dataset.browsingSource", {
|
||||
|
|
|
|||
|
|
@ -192,7 +192,6 @@ export function ParamsSection(): ReactElement {
|
|||
const showVisionImageSize = showVisionLora && !isDeepseekOcr;
|
||||
const [loraOpen, setLoraOpen] = useState(false);
|
||||
const [hyperOpen, setHyperOpen] = useState(false);
|
||||
const needsExpandedHeight = isCpt || (isLora && loraOpen) || hyperOpen;
|
||||
const [ctxInput, setCtxInput] = useState(String(store.contextLength));
|
||||
const ctxAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const ctxItems = CONTEXT_LENGTHS.map(String);
|
||||
|
|
@ -233,11 +232,7 @@ export function ParamsSection(): ReactElement {
|
|||
title={t("studio.params.title")}
|
||||
description={t("studio.params.description")}
|
||||
accent="orange"
|
||||
className={`${
|
||||
needsExpandedHeight
|
||||
? "min-h-studio-config-column"
|
||||
: "h-studio-config-column"
|
||||
} duration-150`}
|
||||
className="min-h-studio-config-column"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ export function TrainingSection() {
|
|||
((!store.isVisionModel && store.isDatasetImage === true) ||
|
||||
(!store.isAudioModel && store.isDatasetAudio === true));
|
||||
const configValidation = validateTrainingConfig(store);
|
||||
const hasMessage = !!(startError || isIncompatible || (!configValidation.ok && configValidation.message));
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -125,7 +124,7 @@ export function TrainingSection() {
|
|||
title={t("studio.training.title")}
|
||||
description={t("studio.training.description")}
|
||||
accent="blue"
|
||||
className={hasMessage ? "min-h-studio-config-column" : "h-studio-config-column"}
|
||||
className="min-h-studio-config-column"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Loss chart */}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import { CPT_TARGET_MODULES, DEFAULT_HYPERPARAMS, LR_DEFAULT_CPT, LR_DEFAULT_FULL, LR_DEFAULT_LORA, STEPS, TARGET_MODULES } from "@/config/training";
|
||||
import { authFetch } from "@/features/auth";
|
||||
import { getHfToken, mirrorHfTokenInto, useHfTokenStore } from "@/features/hub";
|
||||
import { isAdapterMethod } from "@/types/training";
|
||||
import type { DatasetFormat } from "@/types/training";
|
||||
import type { ModelType, StepNumber, TrainingMethod } from "@/types/training";
|
||||
|
|
@ -117,7 +118,9 @@ let _datasetFormatAutoForcedByCpt = false;
|
|||
|
||||
// modelType / isVisionModel / isAudioModel persist so multimodal-only UI
|
||||
// paints right on reload; the model-config fetch still re-derives them.
|
||||
// hfToken mirrors the shared hf-token-store and is persisted there instead.
|
||||
const NON_PERSISTED_STATE_KEYS: ReadonlySet<keyof TrainingConfigState> = new Set([
|
||||
"hfToken",
|
||||
"isCheckingVision",
|
||||
"isEmbeddingModel",
|
||||
"isLoadingModelDefaults",
|
||||
|
|
@ -632,8 +635,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
),
|
||||
);
|
||||
},
|
||||
setHfToken: (hfToken) =>
|
||||
set({ hfToken: hfToken.trim().replace(/^["']+|["']+$/g, "") }),
|
||||
setHfToken: (hfToken) => useHfTokenStore.getState().setToken(hfToken),
|
||||
setDatasetSource: (datasetSource) => set({ datasetSource }),
|
||||
selectHfDataset: (dataset) => {
|
||||
_datasetCheckController?.abort();
|
||||
|
|
@ -923,7 +925,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
_learningRateManuallySet = false;
|
||||
_yamlLearningRate = undefined;
|
||||
clearCptDatasetFormatTracking();
|
||||
set(initialState);
|
||||
set({ ...initialState, hfToken: getHfToken() });
|
||||
},
|
||||
resetToModelDefaults: () => {
|
||||
const { selectedModel } = get();
|
||||
|
|
@ -947,7 +949,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
},
|
||||
{
|
||||
name: "unsloth_training_config_v1",
|
||||
version: 11,
|
||||
version: 12,
|
||||
migrate: (persisted, version) => {
|
||||
const s = persisted as Record<string, unknown>;
|
||||
if (version < 2 && s.datasetSubset == null && s.datasetConfig != null) {
|
||||
|
|
@ -1000,6 +1002,15 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
// own version guard.
|
||||
s.datasetStreaming ??= false;
|
||||
}
|
||||
if (version < 12) {
|
||||
// hfToken moved to the shared hf-token-store; seed it once so an
|
||||
// existing Studio-only token isn't lost.
|
||||
const legacyToken = typeof s.hfToken === "string" ? s.hfToken.trim() : "";
|
||||
if (legacyToken && !getHfToken()) {
|
||||
useHfTokenStore.getState().setToken(legacyToken);
|
||||
}
|
||||
delete s.hfToken;
|
||||
}
|
||||
return s as unknown as TrainingConfigStore;
|
||||
},
|
||||
partialize: partializePersistedState,
|
||||
|
|
@ -1022,3 +1033,8 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
|
|||
},
|
||||
),
|
||||
);
|
||||
|
||||
const unsubscribeHfTokenMirror = mirrorHfTokenInto(useTrainingConfigStore);
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(unsubscribeHfTokenMirror);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2048,15 +2048,11 @@ html[data-chat-font] .aui-root {
|
|||
}
|
||||
}
|
||||
|
||||
/* Fine-tuning Studio: equal default height, expandable when needed (md+) */
|
||||
/* Fine-tuning Studio: equal minimum card height, grows with content (md+) */
|
||||
.min-h-studio-config-column {
|
||||
@apply md:min-h-[520px];
|
||||
}
|
||||
|
||||
.h-studio-config-column {
|
||||
@apply md:h-[520px];
|
||||
}
|
||||
|
||||
[data-streamdown="unordered-list"] {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
|
|
|
|||
82
tests/python/test_get_lora_parameters_bias_fp8_block_size.py
Normal file
82
tests/python/test_get_lora_parameters_bias_fp8_block_size.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_function(name):
|
||||
# Extract a function from kernels/utils.py without importing unsloth (which
|
||||
# needs a GPU / torch / bitsandbytes). The functions exercised here only use
|
||||
# getattr and the _FP8_WEIGHT_DTYPES name on the paths under test.
|
||||
source = Path(__file__).parents[2] / "unsloth" / "kernels" / "utils.py"
|
||||
tree = ast.parse(source.read_text(encoding = "utf-8"))
|
||||
funcs = [
|
||||
node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == name
|
||||
]
|
||||
assert len(funcs) == 1, (name, funcs)
|
||||
namespace = {"getattr": getattr, "_FP8_WEIGHT_DTYPES": ()}
|
||||
module = ast.Module(body = funcs, type_ignores = [])
|
||||
ast.fix_missing_locations(module)
|
||||
exec(compile(module, str(source), "exec"), namespace)
|
||||
return namespace[name]
|
||||
|
||||
|
||||
class _Obj:
|
||||
pass
|
||||
|
||||
|
||||
def _make_disabled_block_fp8_proj(block_size):
|
||||
# A merged/disabled projection whose base layer is a block-fp8 weight that
|
||||
# ships a non-default block size on its checkpoint.
|
||||
weight = _Obj()
|
||||
weight.quant_state = _Obj()
|
||||
base_layer = _Obj()
|
||||
base_layer.weight = weight
|
||||
base_layer.quant_method = "fp8"
|
||||
base_layer.block_size = block_size
|
||||
base_layer.bias = None
|
||||
proj = _Obj()
|
||||
proj.base_layer = base_layer
|
||||
proj.merged = True
|
||||
proj.disable_adapters = True
|
||||
return proj, weight.quant_state
|
||||
|
||||
|
||||
def test_bias_variant_propagates_fp8_block_size_on_disabled_path():
|
||||
# Downstream fp8 kernels read getattr(weight_scale, "block_size", [128, 128]),
|
||||
# so the checkpoint's real block size must survive the merged/disabled path,
|
||||
# exactly as it does for the non-bias sibling get_lora_parameters.
|
||||
get_lora_parameters_bias = _load_function("get_lora_parameters_bias")
|
||||
|
||||
proj, weight_scale = _make_disabled_block_fp8_proj([64, 128])
|
||||
get_lora_parameters_bias(proj)
|
||||
|
||||
assert getattr(weight_scale, "block_size", [128, 128]) == [64, 128]
|
||||
|
||||
|
||||
def _make_decompressed_merged_proj():
|
||||
# A merged compressed-tensors layer that was decompressed back to bf16. It keeps
|
||||
# quant_method == "fp8" from the checkpoint metadata, but the live weight is bf16
|
||||
# so there is no quant state to attach a block size to.
|
||||
weight = _Obj()
|
||||
weight.dtype = "bfloat16"
|
||||
base_layer = _Obj()
|
||||
base_layer.weight = weight
|
||||
base_layer.quant_method = "fp8"
|
||||
base_layer.block_size = [128, 128]
|
||||
base_layer.bias = None
|
||||
proj = _Obj()
|
||||
proj.base_layer = base_layer
|
||||
proj.merged = True
|
||||
proj.disable_adapters = True
|
||||
return proj
|
||||
|
||||
|
||||
def test_bias_variant_keeps_none_quant_state_for_decompressed_layer():
|
||||
# Such a layer has no quant state, and fast_linear_forward relies on getting
|
||||
# W_quant None back so it can fall back to a plain matmul, so setting the block
|
||||
# size must not assume a quant state is present.
|
||||
get_lora_parameters_bias = _load_function("get_lora_parameters_bias")
|
||||
|
||||
W, W_quant = get_lora_parameters_bias(_make_decompressed_merged_proj())[:2]
|
||||
|
||||
assert W_quant is None
|
||||
assert getattr(W, "block_size", None) == [128, 128]
|
||||
81
tests/python/test_get_lora_parameters_fp8_block_size.py
Normal file
81
tests/python/test_get_lora_parameters_fp8_block_size.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_function(name):
|
||||
# Extract a function from kernels/utils.py without importing unsloth (which
|
||||
# needs a GPU / torch / bitsandbytes). get_lora_parameters only uses getattr,
|
||||
# hasattr and the _FP8_WEIGHT_DTYPES name on the paths under test.
|
||||
source = Path(__file__).parents[2] / "unsloth" / "kernels" / "utils.py"
|
||||
tree = ast.parse(source.read_text(encoding = "utf-8"))
|
||||
funcs = [
|
||||
node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == name
|
||||
]
|
||||
assert len(funcs) == 1, (name, funcs)
|
||||
namespace = {"getattr": getattr, "hasattr": hasattr, "_FP8_WEIGHT_DTYPES": ()}
|
||||
module = ast.Module(body = funcs, type_ignores = [])
|
||||
ast.fix_missing_locations(module)
|
||||
exec(compile(module, str(source), "exec"), namespace)
|
||||
return namespace[name]
|
||||
|
||||
|
||||
class _Obj:
|
||||
pass
|
||||
|
||||
|
||||
def _make_disabled_block_fp8_proj(block_size):
|
||||
# A merged/disabled projection whose base layer is a block-fp8 weight that
|
||||
# ships a non-default block size on its checkpoint.
|
||||
weight = _Obj()
|
||||
weight.quant_state = _Obj()
|
||||
base_layer = _Obj()
|
||||
base_layer.weight = weight
|
||||
base_layer.quant_method = "fp8"
|
||||
base_layer.block_size = block_size
|
||||
proj = _Obj()
|
||||
proj.base_layer = base_layer
|
||||
proj.merged = True
|
||||
proj.disable_adapters = True
|
||||
return proj, weight.quant_state
|
||||
|
||||
|
||||
def test_propagates_fp8_block_size_on_disabled_path():
|
||||
# get_lora_parameters already sets block_size before its early return; downstream
|
||||
# fp8 kernels read getattr(weight_scale, "block_size", [128, 128]), so the
|
||||
# checkpoint's real block size must survive the merged/disabled path.
|
||||
get_lora_parameters = _load_function("get_lora_parameters")
|
||||
|
||||
proj, weight_scale = _make_disabled_block_fp8_proj([64, 128])
|
||||
get_lora_parameters(proj)
|
||||
|
||||
assert getattr(weight_scale, "block_size", [128, 128]) == [64, 128]
|
||||
|
||||
|
||||
def _make_decompressed_merged_proj():
|
||||
# A merged compressed-tensors layer that was decompressed back to bf16. It keeps
|
||||
# quant_method == "fp8" from the checkpoint metadata, but the live weight is bf16
|
||||
# so there is no quant state to attach a block size to.
|
||||
weight = _Obj()
|
||||
weight.dtype = "bfloat16"
|
||||
base_layer = _Obj()
|
||||
base_layer.weight = weight
|
||||
base_layer.quant_method = "fp8"
|
||||
base_layer.block_size = [128, 128]
|
||||
proj = _Obj()
|
||||
proj.base_layer = base_layer
|
||||
proj.merged = True
|
||||
proj.disable_adapters = True
|
||||
return proj
|
||||
|
||||
|
||||
def test_keeps_none_quant_state_for_decompressed_layer():
|
||||
# Mirrors the get_lora_parameters_bias guard: with no quant state, assigning
|
||||
# W_quant.block_size must not assume one is present, or it raises AttributeError
|
||||
# on None. fast_lora relies on getting W_quant None back to fall back to a plain
|
||||
# matmul, so this path must stay crash-free.
|
||||
get_lora_parameters = _load_function("get_lora_parameters")
|
||||
|
||||
W, W_quant = get_lora_parameters(_make_decompressed_merged_proj())[:2]
|
||||
|
||||
assert W_quant is None
|
||||
assert getattr(W, "block_size", None) == [128, 128]
|
||||
|
|
@ -8,6 +8,8 @@ regressions that pure AST checks cannot (e.g. wrong scheme/suffix/outtype passed
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
import unsloth.save as save_mod
|
||||
|
|
@ -126,6 +128,80 @@ def test_gguf_lora_push_to_hub_is_rejected(tmp_path):
|
|||
)
|
||||
|
||||
|
||||
# The above rejection points users at push_to_hub_gguf(save_method='lora'), so that path
|
||||
# has to work; it is only ever exercised here.
|
||||
|
||||
|
||||
def test_push_to_hub_gguf_lora_dispatches(monkeypatch):
|
||||
seen = {}
|
||||
monkeypatch.setattr(
|
||||
save_mod,
|
||||
"_unsloth_save_lora_gguf",
|
||||
lambda model, tok, sd, **kw: seen.update(kw),
|
||||
)
|
||||
save_mod.unsloth_push_to_hub_gguf(
|
||||
_FakeModel(),
|
||||
"repo/id",
|
||||
tokenizer = object(),
|
||||
save_method = "lora",
|
||||
quantization_method = "q8_0",
|
||||
)
|
||||
assert seen.get("outtype") == "q8_0"
|
||||
assert seen.get("push_to_hub") is True
|
||||
|
||||
|
||||
def test_push_to_hub_gguf_lora_skips_non_main_process(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
save_mod,
|
||||
"_unsloth_save_lora_gguf",
|
||||
lambda *a, **kw: calls.append(kw),
|
||||
)
|
||||
result = save_mod.unsloth_push_to_hub_gguf(
|
||||
_FakeModel(),
|
||||
"repo/id",
|
||||
tokenizer = object(),
|
||||
save_method = "lora",
|
||||
is_main_process = False,
|
||||
)
|
||||
assert result is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_push_to_hub_gguf_skips_non_main_process_before_merged_conversion(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
save_mod,
|
||||
"unsloth_save_pretrained_gguf",
|
||||
lambda **kw: calls.append(kw),
|
||||
)
|
||||
result = save_mod.unsloth_push_to_hub_gguf(
|
||||
_FakeModel(),
|
||||
"repo/id",
|
||||
tokenizer = object(),
|
||||
is_main_process = False,
|
||||
)
|
||||
assert result is None
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_push_to_hub_gguf_preserves_positional_max_shard_size():
|
||||
bound = inspect.signature(save_mod.unsloth_push_to_hub_gguf).bind(
|
||||
_FakeModel(),
|
||||
"repo/id",
|
||||
object(),
|
||||
"q4_k_m",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
"token",
|
||||
"50GB",
|
||||
)
|
||||
assert bound.arguments["max_shard_size"] == "50GB"
|
||||
assert "is_main_process" not in bound.arguments
|
||||
|
||||
|
||||
# -- torchao PTQ / QAT dispatch ------------------------------------------------------------
|
||||
|
||||
|
||||
|
|
|
|||
58
tests/security/test_release_desktop_permissions.py
Normal file
58
tests/security/test_release_desktop_permissions.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Permission-boundary checks for the desktop release workflow."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release-desktop.yml"
|
||||
|
||||
|
||||
def _workflow():
|
||||
return yaml.safe_load(WORKFLOW.read_text(encoding = "utf-8"))
|
||||
|
||||
|
||||
def test_only_publish_job_can_write_repository_contents():
|
||||
workflow = _workflow()
|
||||
assert workflow["permissions"] == {"contents": "read"}
|
||||
|
||||
write_jobs = [
|
||||
name
|
||||
for name, job in workflow["jobs"].items()
|
||||
if job.get("permissions", {}).get("contents") == "write"
|
||||
]
|
||||
assert write_jobs == ["publish-release"]
|
||||
|
||||
|
||||
def test_build_matrix_hands_off_assets_without_release_credentials():
|
||||
jobs = _workflow()["jobs"]
|
||||
build = jobs["build"]
|
||||
publish = jobs["publish-release"]
|
||||
|
||||
assert "permissions" not in build
|
||||
tauri_steps = [
|
||||
step
|
||||
for step in build["steps"]
|
||||
if step.get("uses", "").startswith("tauri-apps/tauri-action@")
|
||||
]
|
||||
assert len(tauri_steps) == 3
|
||||
for step in tauri_steps:
|
||||
assert "GITHUB_TOKEN" not in step.get("env", {})
|
||||
assert not {"releaseId", "tagName", "releaseName"} & step.get("with", {}).keys()
|
||||
|
||||
assert any(
|
||||
step.get("uses", "").startswith("actions/upload-artifact@") for step in build["steps"]
|
||||
)
|
||||
assert any(
|
||||
step.get("uses", "").startswith("actions/download-artifact@") for step in publish["steps"]
|
||||
)
|
||||
assert "build" in publish["needs"]
|
||||
|
||||
release_step = next(
|
||||
step
|
||||
for step in publish["steps"]
|
||||
if step.get("name") == "Create or validate versioned release"
|
||||
)
|
||||
assert "gh release view" in release_step["run"]
|
||||
assert "--json tagName,isDraft,isPrerelease" in release_step["run"]
|
||||
41
tests/studio/test_compact_dropdown_submenus.py
Normal file
41
tests/studio/test_compact_dropdown_submenus.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Compact viewport contracts for nested dropdown menus."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
DROPDOWN_MENU = REPO / "studio/frontend/src/components/ui/dropdown-menu.tsx"
|
||||
FRONTEND_SRC = REPO / "studio/frontend/src"
|
||||
|
||||
|
||||
def test_shared_submenu_uses_its_layout_width_on_mobile():
|
||||
source = DROPDOWN_MENU.read_text(encoding = "utf-8")
|
||||
assert 'import { useIsMobile } from "@/hooks/use-mobile";' in source
|
||||
assert "element.offsetWidth" in source
|
||||
assert "element.getBoundingClientRect().width" not in source
|
||||
assert "new ResizeObserver(updateContentWidth)" in source
|
||||
assert "isMobile && contentWidth > 0 ? -contentWidth : sideOffset" in source
|
||||
assert "sideOffset={compactSideOffset}" in source
|
||||
assert 'isMobile && contentWidth === 0 ? "hidden"' in source
|
||||
assert "-248" not in source
|
||||
|
||||
|
||||
def test_shared_submenu_never_exceeds_the_compact_viewport():
|
||||
source = DROPDOWN_MENU.read_text(encoding = "utf-8")
|
||||
assert "max-w-[calc(100vw-2rem)]" in source
|
||||
|
||||
|
||||
def test_consumers_do_not_duplicate_compact_offset_logic():
|
||||
for path in FRONTEND_SRC.rglob("*.tsx"):
|
||||
if path == DROPDOWN_MENU:
|
||||
continue
|
||||
source = path.read_text(encoding = "utf-8")
|
||||
assert "compactSubmenuOffset" not in source, path
|
||||
|
||||
|
||||
def test_all_submenu_consumers_use_the_shared_primitive():
|
||||
for path in FRONTEND_SRC.rglob("*.tsx"):
|
||||
if path == DROPDOWN_MENU:
|
||||
continue
|
||||
source = path.read_text(encoding = "utf-8")
|
||||
assert "DropdownMenuPrimitive.SubContent" not in source, path
|
||||
35
tests/studio/test_settings_compact_overflow_contract.py
Normal file
35
tests/studio/test_settings_compact_overflow_contract.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Responsive overflow contracts for the settings dialog."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
SETTINGS_DIALOG = REPO / "studio/frontend/src/features/settings/settings-dialog.tsx"
|
||||
API_MONITOR = REPO / "studio/frontend/src/features/settings/components/api-monitor-console.tsx"
|
||||
GENERAL_TAB = REPO / "studio/frontend/src/features/settings/tabs/general-tab.tsx"
|
||||
|
||||
|
||||
def test_dialog_content_can_shrink_inside_the_dialog_grid():
|
||||
source = SETTINGS_DIALOG.read_text(encoding = "utf-8")
|
||||
assert "flex h-full min-h-0 min-w-0 w-full max-sm:flex-col" in source
|
||||
assert "relative flex min-h-0 min-w-0 flex-1 flex-col" in source
|
||||
|
||||
|
||||
def test_api_monitor_entries_and_expanded_text_can_shrink():
|
||||
source = API_MONITOR.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
'<article className="min-w-0 rounded-lg border border-border/70 bg-background">' in source
|
||||
)
|
||||
assert (
|
||||
'<section className="flex min-w-0 flex-col rounded-lg border border-border/70 bg-background">'
|
||||
in source
|
||||
)
|
||||
assert source.count('className="max-h-44 overflow-auto whitespace-pre-wrap break-words') == 2
|
||||
|
||||
|
||||
def test_embedding_model_controls_stack_on_the_narrowest_viewports():
|
||||
source = GENERAL_TAB.read_text(encoding = "utf-8")
|
||||
assert (
|
||||
'className="max-[360px]:flex-col max-[360px]:items-stretch max-[360px]:gap-3"'
|
||||
) in source
|
||||
assert 'className="w-[220px] max-[360px]:min-w-0 max-[360px]:flex-1"' in source
|
||||
|
|
@ -241,6 +241,61 @@ def test_raw_text_loader():
|
|||
os.unlink(test_file)
|
||||
|
||||
|
||||
def test_smart_chunk_text_single_chunk_no_eos_returns_plain_list():
|
||||
"""smart_chunk_text's single-chunk branch must return a plain list for
|
||||
input_ids even when the tokenizer has no eos_token_id, matching the
|
||||
multi-chunk branch's unconditional tolist()/list() conversion."""
|
||||
|
||||
class MockTensor:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def __getitem__(self, idx):
|
||||
return self.data
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
def tolist(self):
|
||||
return self.data
|
||||
|
||||
class MockTokenizerNoEos:
|
||||
def __init__(self):
|
||||
self.eos_token = None
|
||||
self.eos_token_id = None
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
text,
|
||||
return_tensors = None,
|
||||
add_special_tokens = False,
|
||||
):
|
||||
token_ids = list(range(len(text.split())))
|
||||
if return_tensors == "pt":
|
||||
return {"input_ids": [MockTensor(token_ids)]}
|
||||
return {"input_ids": token_ids}
|
||||
|
||||
def decode(
|
||||
self,
|
||||
token_ids,
|
||||
skip_special_tokens = False,
|
||||
):
|
||||
return " ".join(f"word_{i}" for i in token_ids)
|
||||
|
||||
loader = RawTextDataLoader(MockTokenizerNoEos(), chunk_size = 2048, stride = 512)
|
||||
result = loader.smart_chunk_text(
|
||||
"hello world short text", chunk_size = 2048, stride = 512, return_tokenized = True
|
||||
)
|
||||
input_ids = result[0]["input_ids"]
|
||||
assert isinstance(
|
||||
input_ids, list
|
||||
), f"input_ids should be a plain list even without an eos_token_id, got {type(input_ids)}"
|
||||
assert input_ids == [0, 1, 2, 3], f"unexpected input_ids: {input_ids}"
|
||||
print("✅ test_smart_chunk_text_single_chunk_no_eos_returns_plain_list passed!")
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = test_raw_text_loader()
|
||||
success = test_smart_chunk_text_single_chunk_no_eos_returns_plain_list() and success
|
||||
sys.exit(0 if success else 1)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ import os, importlib.util, platform
|
|||
|
||||
os.environ["UNSLOTH_IS_PRESENT"] = "1"
|
||||
|
||||
# Relax Metal's context-store timeout before MLX modules can initialize Metal.
|
||||
# Keep an explicit user value authoritative.
|
||||
if platform.system() == "Darwin" and platform.machine() == "arm64":
|
||||
os.environ.setdefault("AGX_RELAX_CDM_CTXSTORE_TIMEOUT", "1")
|
||||
|
||||
# ── Windows console UTF-8 safety ─────────────────────────────────────────────
|
||||
# Legacy Windows consoles (cp1252) can't encode Unsloth's emoji/box-drawing
|
||||
# glyphs and crash with UnicodeEncodeError. Force stdout/stderr to UTF-8 only on
|
||||
|
|
|
|||
|
|
@ -154,9 +154,9 @@ class RawTextDataLoader:
|
|||
if len(tokens) <= chunk_size:
|
||||
# Fits in a single chunk
|
||||
if return_tokenized:
|
||||
tokens = tokens.tolist() if hasattr(tokens, "tolist") else list(tokens)
|
||||
eos_token_id = getattr(self.tokenizer, "eos_token_id", None)
|
||||
if eos_token_id is not None:
|
||||
tokens = tokens.tolist() if hasattr(tokens, "tolist") else list(tokens)
|
||||
tokens.append(eos_token_id)
|
||||
|
||||
attention_mask = [1] * len(tokens)
|
||||
|
|
|
|||
|
|
@ -325,7 +325,10 @@ def get_lora_parameters(proj):
|
|||
if getattr(base_layer, "quant_method", None) == "fp8":
|
||||
# we need to somehow store and pass this information :)
|
||||
W.block_size = getattr(base_layer, "block_size", [128, 128])
|
||||
W_quant.block_size = W.block_size
|
||||
# A decompressed compressed-tensors layer keeps quant_method == "fp8" while its
|
||||
# weight is back to bf16, so it has no quant state to carry the block size.
|
||||
if W_quant is not None:
|
||||
W_quant.block_size = W.block_size
|
||||
|
||||
# if not hasattr(proj, "disable_adapters") or proj.disable_adapters or proj.merged:
|
||||
if getattr(proj, "disable_adapters", True) or proj.merged:
|
||||
|
|
@ -375,14 +378,17 @@ def get_lora_parameters_bias(proj):
|
|||
if W_quant is None:
|
||||
W_quant = getattr(base_layer, "weight_scale", None)
|
||||
|
||||
# if not hasattr(proj, "disable_adapters") or proj.disable_adapters or proj.merged:
|
||||
if getattr(proj, "disable_adapters", True) or proj.merged:
|
||||
return W, W_quant, None, None, None, base_layer.bias
|
||||
|
||||
if getattr(base_layer, "quant_method", None) == "fp8":
|
||||
# we need to somehow store and pass this information :)
|
||||
W.block_size = getattr(base_layer, "block_size", [128, 128])
|
||||
W_quant.block_size = W.block_size
|
||||
# A decompressed compressed-tensors layer keeps quant_method == "fp8" while its
|
||||
# weight is back to bf16, so it has no quant state to carry the block size.
|
||||
if W_quant is not None:
|
||||
W_quant.block_size = W.block_size
|
||||
|
||||
# if not hasattr(proj, "disable_adapters") or proj.disable_adapters or proj.merged:
|
||||
if getattr(proj, "disable_adapters", True) or proj.merged:
|
||||
return W, W_quant, None, None, None, base_layer.bias
|
||||
|
||||
adapter = getattr(proj, "active_adapters", None)
|
||||
if adapter is None:
|
||||
|
|
|
|||
|
|
@ -3143,6 +3143,7 @@ def unsloth_push_to_hub_gguf(
|
|||
datasets: Optional[List[str]] = None,
|
||||
save_method: str = None,
|
||||
imatrix_file = None,
|
||||
is_main_process: bool = True,
|
||||
):
|
||||
"""
|
||||
Same as .push_to_hub(...) except 4bit weights are auto
|
||||
|
|
@ -3175,11 +3176,11 @@ def unsloth_push_to_hub_gguf(
|
|||
"""
|
||||
if tokenizer is None:
|
||||
raise ValueError("Unsloth: Saving to GGUF must have a tokenizer.")
|
||||
if not is_main_process:
|
||||
return None
|
||||
|
||||
# save_method="lora" exports the adapter itself as a GGUF LoRA (not a merged model).
|
||||
if save_method is not None and str(save_method).lower() == "lora":
|
||||
if not is_main_process:
|
||||
return None # only the main rank converts and uploads, like the local lora branch
|
||||
_qm = quantization_method
|
||||
if isinstance(_qm, (list, tuple)) and len(_qm) == 1:
|
||||
_qm = _qm[0] # the gguf API allows a list; unwrap a single outtype
|
||||
|
|
@ -3233,6 +3234,7 @@ def unsloth_push_to_hub_gguf(
|
|||
first_conversion = first_conversion,
|
||||
push_to_hub = False, # Never push from here
|
||||
token = token, # forwarded so imatrix_file=True can read a gated/private upstream
|
||||
is_main_process = is_main_process,
|
||||
max_shard_size = max_shard_size,
|
||||
safe_serialization = safe_serialization,
|
||||
temporary_location = temporary_location,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue