From d69d60ff19783a24e6933c1efc1637795b12ec4d Mon Sep 17 00:00:00 2001 From: Etherll <61019402+Etherll@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:27:41 +0200 Subject: [PATCH 01/94] perf(studio): upgrade to Vite 8 + auto-install bun for faster frontend builds (#4522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(studio): upgrade to Vite 8 + auto-install bun for 3x faster frontend builds * fix(studio): make bun-to-npm fallback actually reachable setup.sh used run_quiet() for the bun install attempt, but run_quiet calls exit on failure. This killed the script before the npm fallback could run, making the "falling back to npm" branch dead code. Replace the run_quiet call with a direct bun invocation that captures output to a temp file (same pattern, but returns instead of exiting). Also clean up partial node_modules left by a failed bun install before falling back to npm, in both setup.sh and build.sh. Without this, npm inherits a corrupted node_modules tree from the failed bun run. * fix(studio): restore commonjsOptions for dagre CJS interop The previous commit removed build.commonjsOptions, assuming Vite 8's Rolldown handles CJS natively. While optimizeDeps.include covers the dev server (pre-bundling), it does NOT apply to production builds. The resolve.alias still points @dagrejs/dagre to its .cjs.js entry, so without commonjsOptions the production bundle fails to resolve the CJS default export. This causes "TypeError: e is not a function" on /chat after build (while dev mode works fine). Restore the original commonjsOptions block to fix production builds. * fix(studio): use motion/react instead of legacy framer-motion import * fix(studio): address PR review findings for Vite 8 + bun upgrade Fixes: - Remove bun.lock from repo and add to .gitignore (npm is source of truth) - Use & bun install *> $null pattern in setup.ps1 for reliable $LASTEXITCODE - Add Remove-Item node_modules before npm fallback in setup.ps1 - Print bun install failure log in setup.sh before discarding - Add Refresh-Environment after npm install -g bun in setup.ps1 - Tighten Node version check to ^20.19.0 || >=22.12.0 (Vite 8 requirement) - Add engines field to package.json - Use string comparison for _install_ok in build.sh - Remove explicit framer-motion ^11.18.2 from package.json (motion pulls framer-motion ^12.38.0 as its own dependency — the old pin caused a version conflict) * Fix Colab Node bypass and bun.lock stale-build trigger Gate the Colab Node shortcut on NODE_OK=true so Colab environments with a Node version too old for Vite 8 fall through to the nvm install path instead of silently proceeding. Exclude bun.lock from the stale-build probe in both setup.sh and setup.ps1 so it does not force unnecessary frontend rebuilds on every run. --------- Co-authored-by: Daniel Han Co-authored-by: Shine1i --- build.sh | 17 +- studio/frontend/.gitignore | 1 + studio/frontend/bun.lock | 2483 ----------------- studio/frontend/package.json | 10 +- .../src/components/assistant-ui/thread.tsx | 2 +- studio/setup.ps1 | 81 +- studio/setup.sh | 47 +- 7 files changed, 131 insertions(+), 2510 deletions(-) delete mode 100644 studio/frontend/bun.lock diff --git a/build.sh b/build.sh index 3118e8810a..cf8aa02910 100644 --- a/build.sh +++ b/build.sh @@ -29,7 +29,22 @@ _restore_gitignores() { } trap _restore_gitignores EXIT -npm install +# Use bun for install if available (faster), fall back to npm. +_install_ok=false +if command -v bun &>/dev/null; then + if bun install; then + _install_ok=true + else + echo "⚠ bun install failed, falling back to npm" + rm -rf node_modules + fi +fi +if [ "$_install_ok" != "true" ]; then + if ! npm install; then + echo "❌ ERROR: package install failed" >&2 + exit 1 + fi +fi npm run build # outputs to studio/frontend/dist/ _restore_gitignores diff --git a/studio/frontend/.gitignore b/studio/frontend/.gitignore index bf7ac45ef1..f43950477e 100644 --- a/studio/frontend/.gitignore +++ b/studio/frontend/.gitignore @@ -11,6 +11,7 @@ pnpm-debug.log* lerna-debug.log* node_modules +bun.lock dist dist-ssr test/ diff --git a/studio/frontend/bun.lock b/studio/frontend/bun.lock deleted file mode 100644 index 5504aea3d3..0000000000 --- a/studio/frontend/bun.lock +++ /dev/null @@ -1,2483 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "unsloth-theme", - "dependencies": { - "@assistant-ui/react": "^0.12.19", - "@assistant-ui/react-markdown": "^0.12.3", - "@assistant-ui/react-streamdown": "^0.1.2", - "@base-ui/react": "^1.2.0", - "@dagrejs/dagre": "^2.0.4", - "@dagrejs/graphlib": "^3.0.4", - "@fontsource-variable/figtree": "^5.2.10", - "@fontsource-variable/inter": "^5.2.8", - "@fontsource-variable/space-grotesk": "^5.2.10", - "@hugeicons/core-free-icons": "^3.1.1", - "@hugeicons/react": "^1.1.5", - "@huggingface/hub": "^2.9.0", - "@langchain/core": "^1.1.27", - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-label": "^2.1.8", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", - "@streamdown/cjk": "1.0.2", - "@streamdown/code": "1.0.2", - "@streamdown/math": "1.0.2", - "@streamdown/mermaid": "1.0.2", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/react-router": "^1.159.10", - "@tanstack/react-table": "^8.21.3", - "@toolwind/corner-shape": "^0.0.8-3", - "@types/canvas-confetti": "^1.9.0", - "@xyflow/react": "^12.10.0", - "assistant-stream": "^0.3.2", - "canvas-confetti": "^1.9.4", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "date-fns": "^4.1.0", - "dexie": "^4.3.0", - "framer-motion": "^11.18.2", - "js-yaml": "^4.1.1", - "katex": "^0.16.28", - "lucide-react": "^0.577.0", - "mammoth": "^1.11.0", - "motion": "^12.34.0", - "next": "^16.1.6", - "next-themes": "^0.4.6", - "radix-ui": "^1.4.3", - "react": "^19.2.4", - "react-day-picker": "^9.13.2", - "react-dom": "^19.2.4", - "react-resizable-panels": "^4.6.4", - "recharts": "3.7.0", - "remark-gfm": "^4.0.1", - "shadcn": "^3.8.4", - "sonner": "^2.0.7", - "streamdown": "2.3.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", - "tw-animate-css": "^1.4.0", - "tw-shimmer": "^0.4.6", - "unpdf": "^1.4.0", - "zustand": "^5.0.11", - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@eslint/js": "^9.39.1", - "@types/js-yaml": "^4.0.9", - "@types/node": "^24.10.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.26", - "globals": "^16.5.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.55.0", - "vite": "^7.3.1", - }, - }, - }, - "packages": { - "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], - - "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], - - "@assistant-ui/core": ["@assistant-ui/core@0.1.7", "", { "dependencies": { "assistant-stream": "^0.3.6", "nanoid": "^5.1.6" }, "peerDependencies": { "@assistant-ui/store": "^0.2.3", "@assistant-ui/tap": "^0.5.3", "@types/react": "*", "assistant-cloud": "^0.1.22", "react": "^18 || ^19", "zustand": "^5.0.11" }, "optionalPeers": ["@types/react", "assistant-cloud", "react", "zustand"] }, "sha512-219T42ihVOicbJXZLWgD2CW5Bylg9Nk7geC331X4RfJxTDYlm2zIjViGlGaqfj6URXBp6kMulO2BTUrHGmAvdw=="], - - "@assistant-ui/react": ["@assistant-ui/react@0.12.19", "", { "dependencies": { "@assistant-ui/core": "^0.1.7", "@assistant-ui/store": "^0.2.3", "@assistant-ui/tap": "^0.5.3", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.22", "assistant-stream": "^0.3.6", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-scAf0o8cwjuHT9Y44EFGXcE2y6BSmpeMvt0NxOn8+Y/HBlNttQMLNvrM0p2AjacXCUufagiafAnWybzBV3nKEQ=="], - - "@assistant-ui/react-markdown": ["@assistant-ui/react-markdown@0.12.4", "", { "dependencies": { "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "classnames": "^2.5.1", "react-markdown": "^10.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-6TD9guiuLJxJoOwSjNHUYAVma2ctDCG9uypUqKHE0OUhDwTDD3NsMvTnQ0n0Lh8nnCEwVglOwKKlSEYpV7SnWA=="], - - "@assistant-ui/react-streamdown": ["@assistant-ui/react-streamdown@0.1.3", "", { "dependencies": { "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "streamdown": "^2.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@streamdown/cjk": "^1.0.0", "@streamdown/code": "^1.0.0", "@streamdown/math": "^1.0.0", "@streamdown/mermaid": "^1.0.0", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@streamdown/cjk", "@streamdown/code", "@streamdown/math", "@streamdown/mermaid", "@types/react"] }, "sha512-n1UCjXQ3svmDtJBMJj/vXqz/BqAQBuy7myrXeymz2tD9l+ENQgqu2JY5ir3J19juJTe5lsi/P3+tOJ2C1jc/nw=="], - - "@assistant-ui/store": ["@assistant-ui/store@0.2.3", "", { "dependencies": { "use-effect-event": "^2.0.3" }, "peerDependencies": { "@assistant-ui/tap": "^0.5.3", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-daStbgSQiX7+csqK6Cvo7A8p8UZkTCSMxBHxbhJvwrlVbp7BRJWTxq3U3rpTkSGIar23SXIyVRRfXU8VW7pswA=="], - - "@assistant-ui/tap": ["@assistant-ui/tap@0.5.3", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-wy06ksqF2LfFxe4JXy31Ns89N/be1Dy3c+mG363cFHFp3CbLkRu8CrCN2SQSgCkXt628E+D8QyzqdBcl9kD4NQ=="], - - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - - "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], - - "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - - "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], - - "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], - - "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], - - "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - - "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], - - "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], - - "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], - - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@base-ui/react": ["@base-ui/react@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.5", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw=="], - - "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], - - "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], - - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], - - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], - - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], - - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], - - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], - - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], - - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], - - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], - - "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], - - "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], - - "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.1", "", { "dependencies": { "@chevrotain/gast": "11.1.1", "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw=="], - - "@chevrotain/gast": ["@chevrotain/gast@11.1.1", "", { "dependencies": { "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg=="], - - "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.1.1", "", {}, "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg=="], - - "@chevrotain/types": ["@chevrotain/types@11.1.1", "", {}, "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw=="], - - "@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="], - - "@dagrejs/dagre": ["@dagrejs/dagre@2.0.4", "", { "dependencies": { "@dagrejs/graphlib": "3.0.4" } }, "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA=="], - - "@dagrejs/graphlib": ["@dagrejs/graphlib@3.0.4", "", {}, "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg=="], - - "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], - - "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="], - - "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.4", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" } }, "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ=="], - - "@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="], - - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - - "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], - - "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], - - "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="], - - "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - - "@fontsource-variable/figtree": ["@fontsource-variable/figtree@5.2.10", "", {}, "sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA=="], - - "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], - - "@fontsource-variable/space-grotesk": ["@fontsource-variable/space-grotesk@5.2.10", "", {}, "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w=="], - - "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], - - "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@3.1.1", "", {}, "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg=="], - - "@hugeicons/react": ["@hugeicons/react@1.1.5", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-JX/iDz3oO7hWdVqbjwFwRrAjHk8h2vI+mBkNzp4JcXG3t4idoupfjon73nLOA7cr27m0M8hrRC1Q2h6nEBGKVA=="], - - "@huggingface/hub": ["@huggingface/hub@2.10.3", "", { "dependencies": { "@huggingface/tasks": "^0.19.85" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-qSk4FcVFdTGx0lNpFyy7p2KwgAPCsjM2+tupG/MGToEvUGVLsy+dCmela1BcU/VvJNweCtnH5HwdNr7IQa4Zzw=="], - - "@huggingface/tasks": ["@huggingface/tasks@0.19.86", "", {}, "sha512-eab/6J9m+0Z8xw3X2EPPioMLIjFNYjox9nONTmzzgWj0vq6+iMWsMt4tlwrZKLlxxJbFp+acn20VXZi3ejLlng=="], - - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], - - "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - - "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], - - "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], - - "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], - - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], - - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], - - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], - - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], - - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], - - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], - - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], - - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], - - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], - - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], - - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], - - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], - - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], - - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], - - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], - - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], - - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], - - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], - - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], - - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], - - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], - - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], - - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], - - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], - - "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], - - "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], - - "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], - - "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], - - "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@langchain/core": ["@langchain/core@1.1.28", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "uuid": "^10.0.0", "zod": "^3.25.76 || ^4" } }, "sha512-6FAGdezEp8zHY92LtnsAiv54KaG41nBdsuukk+R+1484edV20cVOyIc36ANuGKPx0pmYFCBWhCUdO0jxB/zn2Q=="], - - "@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="], - - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], - - "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], - - "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], - - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], - - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], - - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], - - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], - - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], - - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], - - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], - - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], - - "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], - - "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], - - "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], - - "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], - - "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], - - "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - - "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], - - "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], - - "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], - - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - - "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], - - "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], - - "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="], - - "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], - - "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], - - "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], - - "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], - - "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], - - "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], - - "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], - - "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], - - "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], - - "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], - - "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], - - "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], - - "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], - - "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], - - "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], - - "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], - - "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], - - "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="], - - "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], - - "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], - - "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], - - "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], - - "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="], - - "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], - - "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="], - - "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="], - - "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], - - "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], - - "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], - - "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], - - "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], - - "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], - - "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], - - "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], - - "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], - - "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], - - "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], - - "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], - - "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], - - "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], - - "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], - - "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="], - - "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], - - "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], - - "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="], - - "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], - - "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], - - "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], - - "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], - - "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], - - "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], - - "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], - - "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], - - "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], - - "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], - - "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], - - "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - - "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], - - "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], - - "@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="], - - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="], - - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA=="], - - "@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="], - - "@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="], - - "@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], - - "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], - - "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], - - "@streamdown/cjk": ["@streamdown/cjk@1.0.2", "", { "dependencies": { "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-5OOuZjj2Lnae92Zmg2gA5hloSbcKj25gv+QY4iKbYI+iRsiGWbgmYxmgxNUSO9SR6BKOCy783UHN1HM/QEUpdw=="], - - "@streamdown/code": ["@streamdown/code@1.0.2", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-QKLS3sC8no5y0YvhGLA+ZjtNhznWU09IvFcjRKgSA35ulckMLw3b5T1ha+o1DaW8BS8l0zceLPFZa3/X9+agWQ=="], - - "@streamdown/math": ["@streamdown/math@1.0.2", "", { "dependencies": { "katex": "^0.16.27", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g=="], - - "@streamdown/mermaid": ["@streamdown/mermaid@1.0.2", "", { "dependencies": { "mermaid": "^11.12.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw=="], - - "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - - "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], - - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], - - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], - - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], - - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], - - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], - - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], - - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], - - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], - - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], - - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], - - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], - - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], - - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], - - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], - - "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], - - "@tanstack/react-router": ["@tanstack/react-router@1.162.9", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.162.9", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-APbwKAF+YgSNpHAaA+FdgrmfI/7+qa9hApuVO9+P0IVksJayNIWFQ/6AFG90WQiTYWk64RI1R9cFV2K9Z+j2pQ=="], - - "@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="], - - "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], - - "@tanstack/router-core": ["@tanstack/router-core@1.162.9", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eG7C0oVtZbFOkfvsaF8UyGuNjEc1BfIfD5EzQNwG4vqLKOAyY5SMFBCNjabAi2sglRhL0ZOwKon1SExusU5fxA=="], - - "@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], - - "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], - - "@toolwind/corner-shape": ["@toolwind/corner-shape@0.0.8-3", "", { "dependencies": { "@types/node": "^20.4.1" } }, "sha512-MPIF81F2bhtXbzEeXF0vnL+PKpnopCHOzBspOkK8osMzWQvPUujZn2XZOMdsu4DF6wsVbbRYQtdsJr486HmIPQ=="], - - "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - - "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], - - "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], - - "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], - - "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], - - "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], - - "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], - - "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], - - "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], - - "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], - - "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], - - "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], - - "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], - - "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], - - "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], - - "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], - - "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], - - "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], - - "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], - - "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], - - "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], - - "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], - - "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], - - "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], - - "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], - - "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], - - "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], - - "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], - - "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], - - "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], - - "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], - - "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], - - "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], - - "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], - - "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], - - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - - "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], - - "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], - - "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - - "@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], - - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - - "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - - "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], - - "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - - "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - - "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], - - "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], - - "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], - - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], - - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], - - "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], - - "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], - - "@xyflow/react": ["@xyflow/react@12.10.1", "", { "dependencies": { "@xyflow/system": "0.0.75", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q=="], - - "@xyflow/system": ["@xyflow/system@0.0.75", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ=="], - - "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - - "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - - "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], - - "assistant-cloud": ["assistant-cloud@0.1.22", "", { "dependencies": { "assistant-stream": "^0.3.6" } }, "sha512-AEE9shV+oFrGDv/MRTRERctNKpIYS0n34UpAQXXICiOkSWD6QZnS1ljLqruFko7fJoT5CIWq8dNeJWdzQLTBLg=="], - - "assistant-stream": ["assistant-stream@0.3.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-Ne/uTseMIiZx740dTbr/SWxONM8nYj4Z5BRmUfqQN+TNgtOCgWOlC/oTUQ+A7LIUHtmGbcoyZwDf8yd2RASnDA=="], - - "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], - - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], - - "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], - - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - - "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - - "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], - - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - - "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], - - "canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="], - - "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], - - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], - - "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], - - "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - - "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], - - "chevrotain": ["chevrotain@11.1.1", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.1", "@chevrotain/gast": "11.1.1", "@chevrotain/regexp-to-ast": "11.1.1", "@chevrotain/types": "11.1.1", "@chevrotain/utils": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ=="], - - "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], - - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], - - "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], - - "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], - - "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], - - "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], - - "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], - - "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], - - "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], - - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], - - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - - "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], - - "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], - - "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], - - "console-table-printer": ["console-table-printer@2.15.0", "", { "dependencies": { "simple-wcswidth": "^1.1.2" } }, "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw=="], - - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], - - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], - - "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], - - "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - - "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], - - "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], - - "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], - - "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], - - "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], - - "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], - - "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], - - "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], - - "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], - - "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], - - "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], - - "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], - - "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], - - "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], - - "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], - - "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], - - "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], - - "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], - - "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], - - "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], - - "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], - - "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], - - "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], - - "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], - - "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], - - "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], - - "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], - - "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], - - "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], - - "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], - - "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], - - "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], - - "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], - - "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], - - "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], - - "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], - - "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], - - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - - "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], - - "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], - - "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], - - "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], - - "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], - - "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], - - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - - "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], - - "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], - - "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], - - "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], - - "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], - - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], - - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], - - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - - "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - - "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], - - "dexie": ["dexie@4.3.0", "", {}, "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug=="], - - "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], - - "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], - - "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], - - "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], - - "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="], - - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], - - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], - - "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], - - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], - - "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], - - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], - - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], - - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - - "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - - "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], - - "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], - - "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - - "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], - - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - - "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - - "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], - - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - - "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], - - "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], - - "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], - - "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], - - "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - - "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], - - "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - - "graphql": ["graphql@16.13.0", "", {}, "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA=="], - - "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], - - "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], - - "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], - - "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], - - "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], - - "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], - - "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], - - "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], - - "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], - - "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], - - "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], - - "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], - - "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], - - "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], - - "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - - "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], - - "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - - "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], - - "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], - - "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], - - "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - - "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], - - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - - "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], - - "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], - - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - - "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], - - "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - - "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], - - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - - "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], - - "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], - - "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - - "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], - - "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], - - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - - "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], - - "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], - - "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], - - "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], - - "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], - - "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], - - "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], - - "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - - "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], - - "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], - - "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - - "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], - - "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], - - "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - - "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - - "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], - - "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], - - "katex": ["katex@0.16.33", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], - - "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], - - "langium": ["langium@4.2.1", "", { "dependencies": { "chevrotain": "~11.1.1", "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ=="], - - "langsmith": ["langsmith@0.5.6", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^5.6.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-T/RA2l2MsTYX0z1aW8rQ2hBQZEOuXV2v/6tkfG6R5EotJTKMpw1dERCbvP8ezOP8otyWfnNlQA88ZnMRsQ7CHA=="], - - "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], - - "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], - - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], - - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], - - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], - - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], - - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], - - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], - - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], - - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], - - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], - - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], - - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], - - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - - "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], - - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - - "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], - - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], - - "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], - - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="], - - "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - - "mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="], - - "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], - - "marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], - - "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], - - "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], - - "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], - - "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], - - "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], - - "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], - - "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], - - "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], - - "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], - - "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], - - "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], - - "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], - - "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], - - "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], - - "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], - - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - - "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], - - "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - - "mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="], - - "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], - - "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], - - "micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="], - - "micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.3.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gSPnxgHDDqXYOBvQRq6lerrq9mjDhdtKn+7XETuXjxWcL62yZEfUdA28Ml1I2vDIPfAOIKLa0h2XDSGkInGHFQ=="], - - "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="], - - "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], - - "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], - - "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], - - "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], - - "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], - - "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], - - "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], - - "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], - - "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], - - "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], - - "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], - - "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], - - "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], - - "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], - - "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], - - "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], - - "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], - - "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], - - "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], - - "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], - - "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], - - "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], - - "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], - - "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], - - "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], - - "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], - - "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], - - "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], - - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - - "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], - - "minimatch": ["minimatch@3.1.3", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA=="], - - "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - - "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], - - "motion": ["motion@12.34.3", "", { "dependencies": { "framer-motion": "^12.34.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw=="], - - "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], - - "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], - - "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], - - "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], - - "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], - - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - - "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], - - "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], - - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], - - "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], - - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], - - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], - - "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], - - "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], - - "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], - - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], - - "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], - - "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], - - "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], - - "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], - - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], - - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - - "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], - - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - - "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], - - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - - "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - - "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - - "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], - - "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - - "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - - "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - - "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], - - "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], - - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - - "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], - - "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], - - "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], - - "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], - - "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], - - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - - "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], - - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - - "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - - "react-day-picker": ["react-day-picker@9.13.2", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg=="], - - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - - "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="], - - "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], - - "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - - "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], - - "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], - - "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - - "react-resizable-panels": ["react-resizable-panels@4.6.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-pmQP6qv9KmsesNMvWVNvVfVJAwYSOWWbAOAtrPR8Cre20+j1NWIlyft0btjtDQE+OepXmI6g3VPrCXQY0oD7+Q=="], - - "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], - - "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], - - "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - - "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], - - "recharts": ["recharts@3.7.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="], - - "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], - - "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], - - "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], - - "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], - - "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], - - "rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="], - - "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], - - "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], - - "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], - - "remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="], - - "remark-cjk-friendly-gfm-strikethrough": ["remark-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly-gfm-strikethrough": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-bXfMZtsaomK6ysNN/UGRIcasQAYkC10NtPmP0oOHOV8YOhA2TXmwRXCku4qOzjIFxAPfish5+XS0eIug2PzNZA=="], - - "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], - - "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], - - "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], - - "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], - - "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], - - "remend": ["remend@1.2.1", "", {}, "sha512-4wC12bgXsfKAjF1ewwkNIQz5sqewz/z1xgIgjEMb3r1pEytQ37F0Cm6i+OhbTWEvguJD7lhOUJhK5fSasw9f0w=="], - - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - - "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], - - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - - "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - - "rettime": ["rettime@0.10.1", "", {}, "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw=="], - - "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], - - "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], - - "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], - - "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], - - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - - "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], - - "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], - - "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], - - "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - - "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], - - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], - - "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], - - "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], - - "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - - "shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="], - - "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "simple-wcswidth": ["simple-wcswidth@1.1.2", "", {}, "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw=="], - - "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], - - "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], - - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], - - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - - "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - - "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], - - "streamdown": ["streamdown@2.3.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.2.1", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-OqS3by/lt91lSicE8RQP2nTsYI6Q/dQgGP2vcyn9YesCmRHhNjswAuBAZA1z0F4+oBU3II/eV51LqjCqwTb1lw=="], - - "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], - - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - - "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], - - "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], - - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], - - "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], - - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - - "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], - - "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - - "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - - "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], - - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - - "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - - "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], - - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], - - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], - - "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], - - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - - "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], - - "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], - - "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], - - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - - "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], - - "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], - - "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], - - "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], - - "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], - - "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], - - "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], - - "tw-shimmer": ["tw-shimmer@0.4.6", "", { "peerDependencies": { "tailwindcss": ">=4.0.0-0" } }, "sha512-Wg3Qy9bcIHw6v2hqFzsvBiuIVHey2HyjDPYY/ozkDCWDYNPirxs1GoIs8FCrNtc0YTb+/wuSySAB7DjbTY6uGw=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], - - "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], - - "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], - - "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], - - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - - "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], - - "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], - - "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], - - "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], - - "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], - - "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], - - "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], - - "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], - - "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], - - "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - - "unpdf": ["unpdf@1.4.0", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA=="], - - "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], - - "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - - "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], - - "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], - - "use-effect-event": ["use-effect-event@2.0.3", "", { "peerDependencies": { "react": "^18.3 || ^19.0.0-0" } }, "sha512-fz1en+z3fYXCXx3nMB8hXDMuygBltifNKZq29zDx+xNJ+1vEs6oJlYd9sK31vxJ0YI534VUsHEBY0k2BATsmBQ=="], - - "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], - - "use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="], - - "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - - "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], - - "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], - - "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], - - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - - "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], - - "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], - - "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - - "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], - - "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], - - "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], - - "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], - - "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], - - "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], - - "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], - - "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], - - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - - "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - - "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], - - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - - "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], - - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - - "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], - - "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - - "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], - - "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - - "@assistant-ui/core/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="], - - "@assistant-ui/react/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="], - - "@assistant-ui/react/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], - - "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], - - "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - - "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - - "@radix-ui/react-accordion/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-alert-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-collapsible/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-context-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-context-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-form/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], - - "@radix-ui/react-form/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-hover-card/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-menubar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-menubar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-navigation-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-one-time-password-field/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-password-toggle-field/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-radio-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-toast/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-toast/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-toggle-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-toolbar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-toolbar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], - - "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], - - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], - - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - - "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "@toolwind/corner-shape/@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], - - "@ts-morph/common/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], - - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], - - "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], - - "assistant-cloud/assistant-stream": ["assistant-stream@0.3.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-NdtSRrQfWCDA/aqQ1xhobf/xnhuMZkhFAw9xzAt5iAoL3ouxVXOowSRN87OL4MYBQEvqtcjw9/CE6YcsXoBtuw=="], - - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], - - "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], - - "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], - - "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], - - "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], - - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - - "langsmith/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "langsmith/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], - - "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], - - "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - - "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], - - "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], - - "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - - "motion/framer-motion": ["framer-motion@12.34.3", "", { "dependencies": { "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q=="], - - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - - "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - - "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - - "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], - - "radix-ui/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], - - "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], - - "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - - "radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], - - "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - - "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], - - "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], - - "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], - - "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - - "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], - - "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], - - "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], - - "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "@radix-ui/react-accordion/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-avatar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-collapsible/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-context-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-form/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-hover-card/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-menubar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-navigation-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-toast/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-toolbar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "@toolwind/corner-shape/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], - - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - - "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], - - "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], - - "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], - - "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], - - "motion/framer-motion/motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="], - - "motion/framer-motion/motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], - - "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - } -} diff --git a/studio/frontend/package.json b/studio/frontend/package.json index 4b40759d62..d9bab4de7e 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -35,7 +38,7 @@ "@streamdown/code": "1.0.2", "@streamdown/math": "1.0.2", "@streamdown/mermaid": "1.0.2", - "@tailwindcss/vite": "^4.1.18", + "@tailwindcss/vite": "^4.2.2", "@tanstack/react-router": "^1.159.10", "@tanstack/react-table": "^8.21.3", "@toolwind/corner-shape": "^0.0.8-3", @@ -48,7 +51,6 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "dexie": "^4.3.0", - "framer-motion": "^11.18.2", "js-yaml": "^4.1.1", "katex": "^0.16.28", "lucide-react": "^0.577.0", @@ -80,13 +82,13 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "typescript": "~5.9.3", "typescript-eslint": "^8.55.0", - "vite": "^7.3.1" + "vite": "^8.0.1" } } diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index e5528f7ee0..0c07e133fb 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -36,7 +36,7 @@ import { useAuiEvent, useAuiState, } from "@assistant-ui/react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { ArrowDownIcon, ArrowUpIcon, diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 8966449423..d429958bf1 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -728,16 +728,22 @@ if ($IsPipInstall) { Write-Host "[OK] Running from pip install - frontend already bundled, skipping Node/npm check" -ForegroundColor Green } else { # setup.sh installs Node LTS (v22) via nvm. We enforce the same range here: - # Node >= 20, npm >= 11. + # Vite 8 requires Node ^20.19.0 || >=22.12.0, npm >= 11. $NeedNode = $true try { $NodeVersion = (node -v 2>$null) $NpmVersion = (npm -v 2>$null) if ($NodeVersion -and $NpmVersion) { - $NodeMajor = [int]($NodeVersion -replace 'v','').Split('.')[0] + $NodeParts = ($NodeVersion -replace 'v','').Split('.') + $NodeMajor = [int]$NodeParts[0] + $NodeMinor = [int]$NodeParts[1] $NpmMajor = [int]$NpmVersion.Split('.')[0] - if ($NodeMajor -ge 20 -and $NpmMajor -ge 11) { + # Vite 8: ^20.19.0 || >=22.12.0 + $NodeOk = ($NodeMajor -eq 20 -and $NodeMinor -ge 19) -or + ($NodeMajor -eq 22 -and $NodeMinor -ge 12) -or + ($NodeMajor -ge 23) + if ($NodeOk -and $NpmMajor -ge 11) { Write-Host "[OK] Node $NodeVersion and npm $NpmVersion already meet requirements." -ForegroundColor Green $NeedNode = $false } else { @@ -761,6 +767,24 @@ if ($IsPipInstall) { } Write-Host "[OK] Node $(node -v) | npm $(npm -v)" -ForegroundColor Green + + # ── bun (optional, faster package installs) ── + # Installed via npm — Node is already guaranteed above. Works on all platforms. + if (-not (Get-Command bun -ErrorAction SilentlyContinue)) { + Write-Host " Installing bun (faster frontend package installs)..." -ForegroundColor DarkGray + $prevEAP_bun = $ErrorActionPreference + $ErrorActionPreference = "Continue" + npm install -g bun 2>&1 | Out-Null + $ErrorActionPreference = $prevEAP_bun + Refresh-Environment + if (Get-Command bun -ErrorAction SilentlyContinue) { + Write-Host "[OK] bun installed ($(bun --version))" -ForegroundColor Green + } else { + Write-Host "[OK] bun install skipped (npm will be used instead)" -ForegroundColor DarkGray + } + } else { + Write-Host "[OK] bun already installed ($(bun --version))" -ForegroundColor Green + } } # ============================================ @@ -844,10 +868,10 @@ if ($IsPipInstall) { if ($NewerFile) { break } } } - # Also check all top-level files (package.json, bun.lock, vite.config.ts, index.html, etc.) + # Also check all top-level files (package.json, vite.config.ts, index.html, etc.) if (-not $NewerFile) { $NewerFile = Get-ChildItem -Path $FrontendDir -File -ErrorAction SilentlyContinue | - Where-Object { $_.LastWriteTime -gt $DistTime } | + Where-Object { $_.Name -ne "bun.lock" -and $_.LastWriteTime -gt $DistTime } | Select-Object -First 1 } if (-not $NewerFile) { @@ -882,26 +906,47 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) { $WalkDir = Split-Path $WalkDir -Parent } - # npm writes warnings to stderr; lower ErrorActionPreference so PS doesn't - # treat them as terminating errors (same pattern as the pip section below). + # Use bun if available (faster install), fall back to npm. + # Bun is used only as package manager; Node runs the actual build (Vite 8). $prevEAP_npm = $ErrorActionPreference $ErrorActionPreference = "Continue" Push-Location $FrontendDir - npm install 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { - Pop-Location - $ErrorActionPreference = $prevEAP_npm - foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue } - Write-Host "[ERROR] npm install failed (exit code $LASTEXITCODE)" -ForegroundColor Red - Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow - exit 1 + + $UseBun = $null -ne (Get-Command bun -ErrorAction SilentlyContinue) + + if ($UseBun) { + Write-Host " Using bun for package install (faster)" -ForegroundColor DarkGray + & bun install *> $null + $bunExit = $LASTEXITCODE + if ($bunExit -ne 0) { + Write-Host " [WARN] bun install failed (exit $bunExit), falling back to npm" -ForegroundColor Yellow + if (Test-Path "node_modules") { + Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue + } + $UseBun = $false + } } - npm run build 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { + if (-not $UseBun) { + & npm install *> $null + $npmExit = $LASTEXITCODE + if ($npmExit -ne 0) { + Pop-Location + $ErrorActionPreference = $prevEAP_npm + foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue } + Write-Host "[ERROR] npm install failed (exit code $npmExit)" -ForegroundColor Red + Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow + exit 1 + } + } + + # Always use npm to run the build (Node runtime — avoids bun Windows runtime issues) + & npm run build *> $null + $buildExit = $LASTEXITCODE + if ($buildExit -ne 0) { Pop-Location $ErrorActionPreference = $prevEAP_npm foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue } - Write-Host "[ERROR] npm run build failed (exit code $LASTEXITCODE)" -ForegroundColor Red + Write-Host "[ERROR] npm run build failed (exit code $buildExit)" -ForegroundColor Red exit 1 } Pop-Location diff --git a/studio/setup.sh b/studio/setup.sh index 851fcadc81..8f24c58023 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -69,6 +69,7 @@ _NEED_FRONTEND_BUILD=true if [ -d "$SCRIPT_DIR/frontend/dist" ]; then # Check all top-level files (package.json, bun.lock, vite.config.ts, index.html, etc.) _changed=$(find "$SCRIPT_DIR/frontend" -maxdepth 1 -type f \ + ! -name 'bun.lock' \ -newer "$SCRIPT_DIR/frontend/dist" -print -quit 2>/dev/null) # Check src/ and public/ recursively (|| true guards against set -e when dirs are missing) if [ -z "$_changed" ]; then @@ -85,12 +86,18 @@ else NEED_NODE=true if command -v node &>/dev/null && command -v npm &>/dev/null; then NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1) + NODE_MINOR=$(node -v | sed 's/v//' | cut -d. -f2) NPM_MAJOR=$(npm -v | cut -d. -f1) - if [ "$NODE_MAJOR" -ge 20 ] && [ "$NPM_MAJOR" -ge 11 ]; then + # Vite 8 requires Node ^20.19.0 || >=22.12.0 + NODE_OK=false + if [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -ge 19 ]; then NODE_OK=true; fi + if [ "$NODE_MAJOR" -eq 22 ] && [ "$NODE_MINOR" -ge 12 ]; then NODE_OK=true; fi + if [ "$NODE_MAJOR" -ge 23 ]; then NODE_OK=true; fi + if [ "$NODE_OK" = true ] && [ "$NPM_MAJOR" -ge 11 ]; then echo "✅ Node $(node -v) and npm $(npm -v) already meet requirements. Skipping nvm install." NEED_NODE=false else - if [ "$IS_COLAB" = true ]; then + if [ "$IS_COLAB" = true ] && [ "$NODE_OK" = true ]; then echo "✅ Node $(node -v) and npm $(npm -v) detected in Colab." # In Colab, just upgrade npm directly - nvm doesn't work well if [ "$NPM_MAJOR" -lt 11 ]; then @@ -150,6 +157,20 @@ fi echo "✅ Node $(node -v) | npm $(npm -v)" +# ── Install bun (optional, faster package installs) ── +# Uses npm to install bun globally — Node is already guaranteed above, +# avoids platform-specific installers, PATH issues, and admin requirements. +if ! command -v bun &>/dev/null; then + echo " Installing bun (faster frontend package installs)..." + if npm install -g bun > /dev/null 2>&1 && command -v bun &>/dev/null; then + echo "✅ bun installed ($(bun --version))" + else + echo " bun install skipped (npm will be used instead)" + fi +else + echo "✅ bun already installed ($(bun --version))" +fi + # ── 5. Build frontend ── cd "$SCRIPT_DIR/frontend" @@ -174,7 +195,27 @@ _restore_gitignores() { } trap _restore_gitignores EXIT -run_quiet "npm install" npm install +# Use bun for install if available (faster), fall back to npm. +# Build always uses npm (Node runtime — avoids bun runtime issues on some platforms). +# NOTE: We intentionally avoid run_quiet for the bun install attempt because +# run_quiet calls exit on failure, which would kill the script before the npm +# fallback can run. Instead we capture output manually and only show it on failure. +if command -v bun &>/dev/null; then + echo " Using bun for package install (faster)" + _bun_log=$(mktemp) + if bun install >"$_bun_log" 2>&1; then + rm -f "$_bun_log" + else + echo " ⚠️ bun install failed, falling back to npm" + echo " bun install output:" + sed 's/^/ | /' "$_bun_log" >&2 + rm -f "$_bun_log" + rm -rf node_modules + run_quiet "npm install" npm install + fi +else + run_quiet "npm install" npm install +fi run_quiet "npm run build" npm run build _restore_gitignores From 7eb48512bce1046f926da8c64b4615509703f940 Mon Sep 17 00:00:00 2001 From: cz-03 Date: Wed, 25 Mar 2026 13:29:01 +0200 Subject: [PATCH 02/94] feat(tokenizer): add get_tokenizer_info() diagnostic helper (#4436) * feat(tokenizer): add get_tokenizer_info() diagnostic helper Adds get_tokenizer_info(tokenizer) to tokenizer_utils.py returning a concise dict of key tokenizer properties class name, is_fast, vocab size, added token count, model_max_length, padding side, special tokens (bos, eos, pad, unk), chat template presence, and total special token count. All fields use getattr(..., None) fallbacks so the function never raises on unusual or partially initialized tokenizers. Exported via __all__ alongside the existing public helpers. Useful for logging, debugging, and surfacing tokenizer state in the Unsloth Studio UI. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix docstring, remove artifact, restore valuable comments in tokenizer_utils.py - Fix get_tokenizer_info() docstring example: correct tokenizer_class to PreTrainedTokenizerFast, vocab_size to 128000, swap added_tokens_count (256) and special_tokens_count (3) to match actual Llama-3.2-1B-Instruct output - Remove accidentally committed "# ... (rest of file unchanged)" diff artifact - Restore fix_sentencepiece_gguf() docstring with llama.cpp upstream link - Restore 10 comments containing upstream URLs, model-specific workarounds, and non-obvious context (issue #292, sentencepiece#121, Starling hack, Kaggle /tmp limit, Deepseek slow tokenizer, twitter/danielhanchen references) * Revert "Fix docstring, remove artifact, restore valuable comments in tokenizer_utils.py" This reverts commit 4e525b734b95e56ab18229c4f0fd4fb97cd1f01a. * Revert all deletions, keep only get_tokenizer_info() addition Restore tokenizer_utils.py to main and add only the new get_tokenizer_info() function and its __all__ entry. All comment removals, dead code cleanup, and formatting changes from the original PR are reverted. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- unsloth/tokenizer_utils.py | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/unsloth/tokenizer_utils.py b/unsloth/tokenizer_utils.py index 96c22f62ff..8be6bb5a5a 100644 --- a/unsloth/tokenizer_utils.py +++ b/unsloth/tokenizer_utils.py @@ -42,6 +42,7 @@ __all__ = [ "check_tokenizer", "add_new_tokens", "fix_sentencepiece_gguf", + "get_tokenizer_info", ] @@ -896,6 +897,55 @@ def check_tokenizer( return convert_to_fast_tokenizer(tokenizer) +def get_tokenizer_info(tokenizer) -> dict: + """Return a concise diagnostic summary of a tokenizer instance. + + Collects key properties into a plain dict suitable for logging, debugging, + or displaying in the Unsloth Studio UI. All fields are safe to access — + missing attributes fall back to ``None`` rather than raising. + + Example output:: + + { + "name_or_path": "unsloth/Llama-3.2-1B-Instruct", + "tokenizer_class": "PreTrainedTokenizerFast", + "is_fast": True, + "vocab_size": 128000, + "added_tokens_count": 256, + "model_max_length": 131072, + "padding_side": "right", + "bos_token": "<|begin_of_text|>", + "eos_token": "<|eot_id|>", + "pad_token": "<|finetune_right_pad_id|>", + "unk_token": None, + "has_chat_template": True, + "special_tokens_count": 3, + } + + Args: + tokenizer: Any HuggingFace ``PreTrainedTokenizer`` or + ``PreTrainedTokenizerFast`` instance. + + Returns: + A ``dict`` of tokenizer properties. Safe to serialize to JSON. + """ + return { + "name_or_path": getattr(tokenizer, "name_or_path", None), + "tokenizer_class": type(tokenizer).__name__, + "is_fast": getattr(tokenizer, "is_fast", False), + "vocab_size": getattr(tokenizer, "vocab_size", None), + "added_tokens_count": len(getattr(tokenizer, "added_tokens_decoder", {})), + "model_max_length": getattr(tokenizer, "model_max_length", None), + "padding_side": getattr(tokenizer, "padding_side", None), + "bos_token": getattr(tokenizer, "bos_token", None), + "eos_token": getattr(tokenizer, "eos_token", None), + "pad_token": getattr(tokenizer, "pad_token", None), + "unk_token": getattr(tokenizer, "unk_token", None), + "has_chat_template": getattr(tokenizer, "chat_template", None) is not None, + "special_tokens_count": len(getattr(tokenizer, "all_special_tokens", [])), + } + + import inspect from inspect import getsource import trl From 3446e0c489cc6c24f33ff26fcf57a892de848a89 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 04:50:23 -0700 Subject: [PATCH 03/94] Add ROCm (AMD GPU) support to studio setup (#4585) * Add support for ROCm in studio setup * Fix ROCm detection bugs: ROCM_PATH resolution, CUDA guard, compiler selection - Set GPU_BACKEND="cuda" when nvcc is found (CUDA path was unreachable) - Guard ROCm detection with `if [ -z "$GPU_BACKEND" ]` so CUDA takes priority on mixed-toolchain hosts - Rename ROCM_PATH to ROCM_HIPCC for the hipcc binary; resolve the actual ROCm root via readlink -f and hipconfig -R into ROCM_ROOT - Export both ROCM_PATH and HIP_PATH as the resolved root directory - Use HIPCXX via hipconfig -l instead of legacy CMAKE_C_COMPILER=hipcc - Switch grep -oP to grep -oE for portability across Linux distros - Use GPU_TARGETS (upstream cmake variable) instead of AMDGPU_TARGETS - Remove stale hardcoded fallback targets; let cmake auto-detect instead * Fix gfx regex to match gfx90a (MI210/MI250/MI250X) The grep and bash regex used {3,4} digits after 'gfx', which silently excluded gfx90a (2 digits + letter 'a') -- the architecture for AMD Instinct MI210, MI250, and MI250X data-center GPUs. Change to {2,4} so all real gfx targets from gfx90a through gfx1200 are matched. --------- Co-authored-by: edamamez --- studio/setup.sh | 69 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/studio/setup.sh b/studio/setup.sh index 8f24c58023..97bfbbfadc 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -490,17 +490,40 @@ rm -rf "$LLAMA_CPP_DIR" echo " Using ccache for faster compilation" fi - # Detect CUDA: check nvcc on PATH, then common install locations + # Detect GPU backend: CUDA (NVIDIA) or ROCm (AMD) + GPU_BACKEND="" + + # Check for CUDA: check nvcc on PATH, then common install locations NVCC_PATH="" if command -v nvcc &>/dev/null; then NVCC_PATH="$(command -v nvcc)" + GPU_BACKEND="cuda" elif [ -x /usr/local/cuda/bin/nvcc ]; then NVCC_PATH="/usr/local/cuda/bin/nvcc" export PATH="/usr/local/cuda/bin:$PATH" + GPU_BACKEND="cuda" elif ls /usr/local/cuda-*/bin/nvcc &>/dev/null 2>&1; then # Pick the newest cuda-XX.X directory NVCC_PATH="$(ls -d /usr/local/cuda-*/bin/nvcc 2>/dev/null | sort -V | tail -1)" export PATH="$(dirname "$NVCC_PATH"):$PATH" + GPU_BACKEND="cuda" + fi + + # Check for ROCm (AMD) only if CUDA was not already selected + ROCM_HIPCC="" + if [ -z "$GPU_BACKEND" ]; then + if command -v hipcc &>/dev/null; then + ROCM_HIPCC="$(command -v hipcc)" + GPU_BACKEND="rocm" + elif [ -x /opt/rocm/bin/hipcc ]; then + ROCM_HIPCC="/opt/rocm/bin/hipcc" + export PATH="/opt/rocm/bin:$PATH" + GPU_BACKEND="rocm" + elif ls /opt/rocm-*/bin/hipcc &>/dev/null 2>&1; then + ROCM_HIPCC="$(ls -d /opt/rocm-*/bin/hipcc 2>/dev/null | sort -V | tail -1)" + export PATH="$(dirname "$ROCM_HIPCC"):$PATH" + GPU_BACKEND="rocm" + fi fi if [ -n "$NVCC_PATH" ]; then @@ -535,9 +558,53 @@ rm -rf "$LLAMA_CPP_DIR" # Multi-threaded nvcc compilation (uses all CPU cores per .cu file) CMAKE_ARGS="$CMAKE_ARGS -DCMAKE_CUDA_FLAGS=--threads=0" + elif [ "$GPU_BACKEND" = "rocm" ]; then + # Resolve hipcc symlinks to find the real ROCm root + _HIPCC_REAL="$(readlink -f "$ROCM_HIPCC" 2>/dev/null || printf '%s' "$ROCM_HIPCC")" + ROCM_ROOT="" + if command -v hipconfig &>/dev/null; then + ROCM_ROOT="$(hipconfig -R 2>/dev/null || true)" + fi + if [ -z "$ROCM_ROOT" ]; then + ROCM_ROOT="$(cd "$(dirname "$_HIPCC_REAL")/.." 2>/dev/null && pwd)" + fi + + echo " Building with ROCm support (AMD GPU, hipcc: $_HIPCC_REAL)..." + CMAKE_ARGS="$CMAKE_ARGS -DGGML_HIP=ON" + export ROCM_PATH="$ROCM_ROOT" + export HIP_PATH="$ROCM_ROOT" + + # Use upstream-recommended HIP compiler (not legacy hipcc-as-CXX) + if command -v hipconfig &>/dev/null; then + _HIP_CLANG_DIR="$(hipconfig -l 2>/dev/null || true)" + [ -n "$_HIP_CLANG_DIR" ] && export HIPCXX="$_HIP_CLANG_DIR/clang" + fi + + # Detect AMD GPU architecture (gfx target) + GPU_TARGETS="" + if command -v rocminfo &>/dev/null; then + _gfx_list=$(rocminfo 2>/dev/null | grep -oE 'gfx[0-9]{2,4}[a-z]?' | sort -u || true) + _valid_gfx="" + for _gfx in $_gfx_list; do + if [[ "$_gfx" =~ ^gfx[0-9]{2,4}[a-z]?$ ]]; then + _valid_gfx="${_valid_gfx}${_valid_gfx:+;}$_gfx" + fi + done + [ -n "$_valid_gfx" ] && GPU_TARGETS="$_valid_gfx" + fi + + if [ -n "$GPU_TARGETS" ]; then + echo " AMD GPU architectures: ${GPU_TARGETS//;/, } -- limiting build to detected targets" + CMAKE_ARGS="$CMAKE_ARGS -DGPU_TARGETS=${GPU_TARGETS}" + else + echo " Could not detect AMD GPU arch -- building for default targets (cmake will auto-detect)" + fi elif [ -d /usr/local/cuda ] || nvidia-smi &>/dev/null; then echo " CUDA driver detected but nvcc not found — building CPU-only" echo " To enable GPU: install cuda-toolkit or add nvcc to PATH" + elif [ -d /opt/rocm ] || command -v rocm-smi &>/dev/null; then + echo " ROCm driver detected but hipcc not found — building CPU-only" + echo " To enable GPU: install rocm-dev or add hipcc to PATH" else echo " Building CPU-only (no CUDA detected)..." fi From 19e9c60a8e5482618e6c81f83cfae891af990e66 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:24:21 +0400 Subject: [PATCH 04/94] Consolidate dual venvs and separate install from update (#4530) * refactor: consolidate dual venvs into single ~/.unsloth/studio/unsloth_studio * refactor: separate install.sh (first-time) from setup.sh (smart update with PyPI version check) * fix: install.sh calls setup.sh directly, keep both setup and update CLI commands * fix: use importlib.resources.files() directly without _path attribute * fix: bootstrap uv before pip upgrade to handle uv venvs without pip * fix: frontend 404 when launched via CLI, add global symlink to ~/.local/bin * feat: add --local flag to install.sh and unsloth studio update for branch testing * fix: resolve repo root from script location for --local installs * feat: add --package flag to install.sh for testing with custom package names * feat: add --package flag to unsloth studio update * fix: always nuke venv in install.sh for clean installs * revert: remove Windows changes, will handle in separate PR * fix: error when --package is passed without an argument * revert: restore Windows scripts to current main * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: always explicitly set STUDIO_LOCAL_INSTALL and STUDIO_PACKAGE_NAME env vars * fix: pass explicit STUDIO_LOCAL_REPO env var for --local installs * fix: align banner box for Setup vs Update labels * deprecate: hide 'unsloth studio setup' command, point users to update/install.sh * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: check stdout not stdin for auto-launch detection (curl pipe fix) * fix: update install URL to unsloth.ai/install.sh * fix: update install.sh usage comments to unsloth.ai/install.sh * fix: use --upgrade-package for base deps to preserve existing torch/CUDA installs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: --local install now also installs unsloth-zoo via base.txt before editable overlay * fix: don't skip base packages for --local installs (editable needs unsloth-zoo) * refactor: move --local full dep install to install.sh, keep SKIP_STUDIO_BASE for all paths * feat: add migration support for old .venv and CWD-based installs in setup.sh * Revert "feat: add migration support for old .venv and CWD-based installs in setup.sh" This reverts commit 301291d0028b61e15acc064829f48be50c764087. * feat: migrate old .venv layout in install.sh instead of always nuking * feat: validate old .venv with torch CUDA test before migration, recovery message on launch failure * fix: try CUDA then fall back to CPU for migration validation * fix: upgrade unsloth/unsloth-zoo with --reinstall-package on migration to preserve torch * remove: delete unused unsloth ui command (use unsloth studio instead) * Fix Windows venv path mismatch between install.ps1, setup.ps1, and studio.py install.ps1 was creating the venv CWD-relative ($VenvName = "unsloth_studio"), setup.ps1 was using an absolute path to ".unsloth\studio\.venv", and studio.py looks for ".unsloth\studio\unsloth_studio". All three paths were different, so the Windows installer would never produce a working Studio setup. install.ps1: - Use absolute $StudioHome + $VenvDir matching the Linux install.sh layout - Add 3-way migration: old .venv at STUDIO_HOME, CWD-relative ~/unsloth_studio from the previous install.ps1, or fresh creation with torch validation - For migrated envs, upgrade unsloth while preserving existing torch/CUDA wheels - Set SKIP_STUDIO_BASE=1 before calling setup.ps1 (matches install.sh behavior) - Fix launch instructions to use the absolute venv path setup.ps1: - Change $VenvDir from ".unsloth\studio\.venv" to ".unsloth\studio\unsloth_studio" - Add SKIP_STUDIO_BASE guard: error out if venv is missing when called from install.ps1 (which should have already created it) - Differentiate "Setup" vs "Update" in banners based on SKIP_STUDIO_BASE * setup.ps1: unconditionally error if venv missing, matching setup.sh setup.sh always errors out if the venv does not exist (line 224-228), telling the user to run install.sh first. setup.ps1 was conditionally creating a bare venv with python -m venv when SKIP_STUDIO_BASE was not set, which would produce an empty venv with no torch or unsloth. Now setup.ps1 matches setup.sh: always error, always point to install.ps1. * Fix --torch-backend=auto CPU solver dead-end on Linux, macOS, and Windows On CPU-only machines, `uv pip install unsloth --torch-backend=auto` falls back to unsloth==2024.8 because the CPU solver cannot satisfy newer unsloth's dependencies. install.ps1 already solved this with a two-step approach; this applies the same fix to install.sh and install_python_stack.py. install.sh: add get_torch_index_url() that detects GPU via nvidia-smi and maps CUDA versions to PyTorch index URLs (matching install.ps1's Get-TorchIndexUrl). Fresh installs now install torch first via explicit --index-url, then install unsloth with --upgrade-package to preserve the pre-installed torch. All 5 --torch-backend=auto removed from primary paths. install.ps1: add fallback else-branch when TorchIndexUrl is empty, using --torch-backend=auto as last resort (matching install.sh). install_python_stack.py: remove unconditional --torch-backend=auto from _build_uv_cmd. Torch is pre-installed by install.sh/setup.ps1 by the time this runs. Callers that need it can set UV_TORCH_BACKEND. Both install.sh and install.ps1 now share the same three-branch logic: migrated env (upgrade-package only), normal (torch-first + index-url), and fallback (--torch-backend=auto if URL detection fails). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Use --reinstall-package for migrated envs on both Linux and Windows For migrated environments (moved from legacy venv location), --reinstall-package is better than --upgrade-package because it forces a clean reinstall even if the same version is already installed. This ensures proper .dist-info and .pyc state in the new venv location. --upgrade-package remains correct for the fresh install path where torch is already installed and we just want to add unsloth without re-resolving torch. * Address review findings: portability, parity, and stale comments - Replace grep -oP (GNU Perl regex) with POSIX sed in get_torch_index_url() so the script works on BSD grep (macOS is already guarded by the Darwin early-return, but Alpine/BusyBox would silently get the wrong CUDA tag) - Add LC_ALL=C before nvidia-smi invocation to prevent locale-dependent output parsing issues - Add warning on stderr when nvidia-smi output is unparseable, matching install.ps1's [WARN] message - Add explicit unsloth-zoo positional arg to install.ps1 migrated path, matching install.sh (--reinstall-package alone won't install it if it was never present in the migrated env) - Fix stale comment in install_python_stack.py line 392 that still claimed --torch-backend=auto is added by _build_uv_cmd - Add sed to test tools directory (function now uses sed instead of grep) * Add --index-url to migrated env path to prevent CPU torch resolution The migrated path runs uv pip install with --reinstall-package for unsloth/unsloth-zoo. While uv should keep existing torch as satisfied, the resolver could still re-resolve torch as a transitive dependency. Without --index-url pointing at the correct CUDA wheel index, the resolver would fall back to plain PyPI and potentially pull CPU-only torch. Adding --index-url $TORCH_INDEX_URL ensures CUDA wheels are available if the resolver needs them. Applied to both install.sh and install.ps1. * Revert --index-url on migrated env path The original install.ps1 on main already handles the migrated path without --index-url and it works correctly. --reinstall-package only forces reinstall of the named packages while uv keeps existing torch as satisfied. No need for the extra flag. * Fix unsloth studio update --local not installing local checkout studio.py sets STUDIO_LOCAL_REPO when --local is passed, but install_python_stack.py never read it. The update path always installed from PyPI regardless of the --local flag. Add a local_repo branch that first updates deps from base.txt (with --upgrade-package to preserve torch), then overlays the local checkout as an editable install with --no-deps. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- install.ps1 | 88 +++++-- install.sh | 261 +++++++++++++++++++-- studio/backend/colab.py | 2 +- studio/install_python_stack.py | 114 +++++++-- studio/setup.ps1 | 27 ++- studio/setup.sh | 196 ++++++---------- tests/python/__init__.py | 0 tests/python/test_cross_platform_parity.py | 137 +++++++++++ tests/python/test_install_python_stack.py | 56 +++++ tests/run_all.sh | 16 ++ tests/sh/test_get_torch_index_url.sh | 128 ++++++++++ unsloth_cli/__init__.py | 2 - unsloth_cli/commands/studio.py | 48 +++- unsloth_cli/commands/ui.py | 103 -------- 14 files changed, 877 insertions(+), 301 deletions(-) create mode 100644 tests/python/__init__.py create mode 100644 tests/python/test_cross_platform_parity.py create mode 100644 tests/python/test_install_python_stack.py create mode 100755 tests/run_all.sh create mode 100755 tests/sh/test_get_torch_index_url.sh delete mode 100644 unsloth_cli/commands/ui.py diff --git a/install.ps1 b/install.ps1 index 1613ec6258..83576d9c75 100644 --- a/install.ps1 +++ b/install.ps1 @@ -5,8 +5,9 @@ function Install-UnslothStudio { $ErrorActionPreference = "Stop" - $VenvName = "unsloth_studio" $PythonVersion = "3.13" + $StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio" + $VenvDir = Join-Path $StudioHome "unsloth_studio" Write-Host "" Write-Host "=========================================" @@ -449,20 +450,59 @@ shell.Run cmd, 0, False return } - # ── Create venv (skip if it already exists and has a valid interpreter) ── + # ── Create venv (migrate old layout if possible, otherwise fresh) ── # Pass the resolved executable path to uv so it does not re-resolve # a version string back to a conda interpreter. - $VenvPython = Join-Path $VenvName "Scripts\python.exe" + if (-not (Test-Path $StudioHome)) { + New-Item -ItemType Directory -Path $StudioHome -Force | Out-Null + } + + $VenvPython = Join-Path $VenvDir "Scripts\python.exe" + $_Migrated = $false + + if (Test-Path $VenvPython) { + # New layout already exists -- nuke for fresh install + Write-Host "==> Removing existing environment for fresh install..." + Remove-Item -Recurse -Force $VenvDir + } elseif (Test-Path (Join-Path $StudioHome ".venv\Scripts\python.exe")) { + # Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating + $OldVenv = Join-Path $StudioHome ".venv" + $OldPy = Join-Path $OldVenv "Scripts\python.exe" + Write-Host "==> Found legacy Studio environment, validating..." + $prevEAP2 = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + & $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null + $torchOk = ($LASTEXITCODE -eq 0) + } catch { $torchOk = $false } + $ErrorActionPreference = $prevEAP2 + if ($torchOk) { + Write-Host " Legacy environment is healthy -- migrating..." + Move-Item -Path $OldVenv -Destination $VenvDir -Force + Write-Host " Moved .venv -> unsloth_studio" + $_Migrated = $true + } else { + Write-Host " Legacy environment failed validation -- creating fresh environment" + Remove-Item -Recurse -Force $OldVenv -ErrorAction SilentlyContinue + } + } elseif (Test-Path (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe")) { + # CWD-relative venv from old install.ps1 -- migrate to absolute path + $CwdVenv = Join-Path $env:USERPROFILE "unsloth_studio" + Write-Host "==> Found CWD-relative Studio environment, migrating to $VenvDir..." + Move-Item -Path $CwdVenv -Destination $VenvDir -Force + Write-Host " Moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio" + $_Migrated = $true + } + if (-not (Test-Path $VenvPython)) { - if (Test-Path $VenvName) { Remove-Item -Recurse -Force $VenvName } - Write-Host "==> Creating Python $($DetectedPython.Version) virtual environment (${VenvName})..." - uv venv $VenvName --python "$($DetectedPython.Path)" + Write-Host "==> Creating Python $($DetectedPython.Version) virtual environment ($VenvDir)..." + uv venv $VenvDir --python "$($DetectedPython.Path)" if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] Failed to create virtual environment (exit code $LASTEXITCODE)" -ForegroundColor Red return } } else { - Write-Host "==> Virtual environment ${VenvName} already exists, skipping creation." + Write-Host "==> Using migrated environment at $VenvDir" } # ── Detect GPU (robust: PATH + hardcoded fallback paths, mirrors setup.ps1) ── @@ -536,15 +576,26 @@ shell.Run cmd, 0, False # CUDA wheels. Missing dependencies (transformers, trl, peft, etc.) # are still pulled in because they are new, not upgrades. # - Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." - uv pip install --python $VenvPython torch torchvision torchaudio --index-url $TorchIndexUrl - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] Failed to install PyTorch (exit code $LASTEXITCODE)" -ForegroundColor Red - return - } + if ($_Migrated) { + # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state + # in the new venv location, while preserving existing torch/CUDA + Write-Host "==> Upgrading unsloth in migrated environment..." + uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.11" unsloth-zoo + } elseif ($TorchIndexUrl) { + Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." + uv pip install --python $VenvPython torch torchvision torchaudio --index-url $TorchIndexUrl + if ($LASTEXITCODE -ne 0) { + Write-Host "[ERROR] Failed to install PyTorch (exit code $LASTEXITCODE)" -ForegroundColor Red + return + } - Write-Host "==> Installing unsloth (this may take a few minutes)..." - uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.11" + Write-Host "==> Installing unsloth (this may take a few minutes)..." + uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.11" + } else { + # Fallback: GPU detection failed to produce a URL -- let uv resolve torch + Write-Host "==> Installing unsloth (this may take a few minutes)..." + uv pip install --python $VenvPython "unsloth>=2026.3.11" --torch-backend=auto + } if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $LASTEXITCODE)" -ForegroundColor Red return @@ -554,7 +605,7 @@ shell.Run cmd, 0, False # setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools, # CUDA Toolkit, Node.js, and other dependencies automatically via winget. Write-Host "==> Running unsloth studio setup..." - $UnslothExe = Join-Path $VenvName "Scripts\unsloth.exe" + $UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe" if (-not (Test-Path $UnslothExe)) { Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow @@ -562,6 +613,8 @@ shell.Run cmd, 0, False Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow return } + # Tell setup.ps1 to skip base package installation (install.ps1 already did it) + $env:SKIP_STUDIO_BASE = "1" & $UnslothExe studio setup if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] unsloth studio setup failed (exit code $LASTEXITCODE)" -ForegroundColor Red @@ -582,12 +635,11 @@ shell.Run cmd, 0, False if ($IsInteractive) { Write-Host "==> Launching Unsloth Studio..." Write-Host "" - $UnslothExe = Join-Path $VenvName "Scripts\unsloth.exe" & $UnslothExe studio -H 0.0.0.0 -p 8888 } else { Write-Host " To launch, run:" Write-Host "" - Write-Host " .\${VenvName}\Scripts\activate" + Write-Host " & `"$VenvDir\Scripts\Activate.ps1`"" Write-Host " unsloth studio -H 0.0.0.0 -p 8888" Write-Host "" } diff --git a/install.sh b/install.sh index 0893955939..ec5008af12 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,35 @@ #!/bin/sh # Unsloth Studio Installer -# Usage (curl): curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/install.sh | sh -# Usage (wget): wget -qO- https://raw.githubusercontent.com/unslothai/unsloth/main/install.sh | sh +# Usage (curl): curl -fsSL https://unsloth.ai/install.sh | sh +# Usage (wget): wget -qO- https://unsloth.ai/install.sh | sh +# Usage (local): ./install.sh --local (install from local repo instead of PyPI) +# Usage (test): ./install.sh --package roland-sloth (install a different package name) set -e -VENV_NAME="unsloth_studio" +# ── Parse flags ── +STUDIO_LOCAL_INSTALL=false +PACKAGE_NAME="unsloth" +_next_is_package=false +for arg in "$@"; do + if [ "$_next_is_package" = true ]; then + PACKAGE_NAME="$arg" + _next_is_package=false + continue + fi + case "$arg" in + --local) STUDIO_LOCAL_INSTALL=true ;; + --package) _next_is_package=true ;; + esac +done + +if [ "$_next_is_package" = true ]; then + echo "❌ ERROR: --package requires an argument." >&2 + exit 1 +fi + PYTHON_VERSION="3.13" +STUDIO_HOME="$HOME/.unsloth/studio" +VENV_DIR="$STUDIO_HOME/unsloth_studio" # ── Helper: download a URL to a file (supports curl and wget) ── download() { @@ -659,32 +683,195 @@ if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then export PATH="$HOME/.local/bin:$PATH" fi -# ── Create venv (skip if it already exists and has a valid interpreter) ── -if [ ! -x "$VENV_NAME/bin/python" ]; then - [ -e "$VENV_NAME" ] && rm -rf "$VENV_NAME" - echo "==> Creating Python ${PYTHON_VERSION} virtual environment (${VENV_NAME})..." - uv venv "$VENV_NAME" --python "$PYTHON_VERSION" -else - echo "==> Virtual environment ${VENV_NAME} already exists, skipping creation." +# ── Create venv (migrate old layout if possible, otherwise fresh) ── +mkdir -p "$STUDIO_HOME" + +_MIGRATED=false + +if [ -x "$VENV_DIR/bin/python" ]; then + # New layout already exists — nuke for fresh install + rm -rf "$VENV_DIR" +elif [ -x "$STUDIO_HOME/.venv/bin/python" ]; then + # Old layout exists — validate before migrating + echo "==> Found legacy Studio environment, validating..." + if "$STUDIO_HOME/.venv/bin/python" -c " +import torch +device = 'cuda' if torch.cuda.is_available() else 'cpu' +A = torch.ones((10, 10), device=device) +B = torch.ones((10, 10), device=device) +C = torch.ones((10, 10), device=device) +D = A + B +E = D @ C +torch.testing.assert_close(torch.unique(E), torch.tensor((20,), device=E.device, dtype=E.dtype)) +" >/dev/null 2>&1; then + echo "✅ Legacy environment is healthy — migrating..." + mv "$STUDIO_HOME/.venv" "$VENV_DIR" + echo " Moved ~/.unsloth/studio/.venv → $VENV_DIR" + _MIGRATED=true + else + echo "⚠️ Legacy environment failed validation — creating fresh environment" + rm -rf "$STUDIO_HOME/.venv" + fi fi +if [ ! -x "$VENV_DIR/bin/python" ]; then + echo "==> Creating Python ${PYTHON_VERSION} virtual environment (${VENV_DIR})..." + uv venv "$VENV_DIR" --python "$PYTHON_VERSION" +else + echo "==> Using migrated environment at ${VENV_DIR}" +fi + +# ── Resolve repo root (for --local installs) ── +_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)" + +# ── Detect GPU and choose PyTorch index URL ── +# Mirrors Get-TorchIndexUrl in install.ps1. +# On CPU-only machines this returns the cpu index, avoiding the solver +# dead-end where --torch-backend=auto resolves to unsloth==2024.8. +get_torch_index_url() { + _base="https://download.pytorch.org/whl" + # macOS: always CPU (no CUDA support) + case "$(uname -s)" in Darwin) echo "$_base/cpu"; return ;; esac + # Try nvidia-smi + _smi="" + if command -v nvidia-smi >/dev/null 2>&1; then + _smi="nvidia-smi" + elif [ -x "/usr/bin/nvidia-smi" ]; then + _smi="/usr/bin/nvidia-smi" + fi + if [ -z "$_smi" ]; then echo "$_base/cpu"; return; fi + # Parse CUDA version from nvidia-smi output (POSIX-safe, no grep -P) + _cuda_ver=$(LC_ALL=C $_smi 2>/dev/null \ + | sed -n 's/.*CUDA Version:[[:space:]]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' \ + | head -1) + if [ -z "$_cuda_ver" ]; then + echo "[WARN] Could not determine CUDA version from nvidia-smi, defaulting to cu126" >&2 + echo "$_base/cu126"; return + fi + _major=${_cuda_ver%%.*} + _minor=${_cuda_ver#*.} + if [ "$_major" -ge 13 ]; then echo "$_base/cu130" + elif [ "$_major" -eq 12 ] && [ "$_minor" -ge 8 ]; then echo "$_base/cu128" + elif [ "$_major" -eq 12 ] && [ "$_minor" -ge 6 ]; then echo "$_base/cu126" + elif [ "$_major" -ge 12 ]; then echo "$_base/cu124" + elif [ "$_major" -ge 11 ]; then echo "$_base/cu118" + else echo "$_base/cpu"; fi +} +TORCH_INDEX_URL=$(get_torch_index_url) + # ── Install unsloth directly into the venv (no activation needed) ── -echo "==> Installing unsloth (this may take a few minutes)..." -uv pip install --python "$VENV_NAME/bin/python" "unsloth>=2026.3.11" --torch-backend=auto +_VENV_PY="$VENV_DIR/bin/python" +if [ "$_MIGRATED" = true ]; then + # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state + # in the new venv location, while preserving existing torch/CUDA + echo "==> Upgrading unsloth in migrated environment..." + uv pip install --python "$_VENV_PY" \ + --reinstall-package unsloth --reinstall-package unsloth-zoo \ + "unsloth>=2026.3.11" unsloth-zoo + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + echo "==> Overlaying local repo (editable)..." + uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + fi +elif [ -n "$TORCH_INDEX_URL" ]; then + # Fresh: Step 1 - install torch from explicit index + echo "==> Installing PyTorch ($TORCH_INDEX_URL)..." + uv pip install --python "$_VENV_PY" torch torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" + # Fresh: Step 2 - install unsloth, preserving pre-installed torch + echo "==> Installing unsloth (this may take a few minutes)..." + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + uv pip install --python "$_VENV_PY" \ + --upgrade-package unsloth "unsloth>=2026.3.11" unsloth-zoo + echo "==> Overlaying local repo (editable)..." + uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + else + uv pip install --python "$_VENV_PY" \ + --upgrade-package unsloth "$PACKAGE_NAME" + fi +else + # Fallback: GPU detection failed to produce a URL -- let uv resolve torch + echo "==> Installing unsloth (this may take a few minutes)..." + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.11" --torch-backend=auto + echo "==> Overlaying local repo (editable)..." + uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + else + uv pip install --python "$_VENV_PY" "$PACKAGE_NAME" --torch-backend=auto + fi +fi # ── Run studio setup ── -# Ensure the venv's Python is on PATH for setup.sh's Python discovery. -# On macOS the system Python may be outside the 3.11-3.13 range that -# setup.sh requires, but uv already installed a compatible interpreter -# inside the venv. -VENV_ABS_BIN="$(cd "$VENV_NAME/bin" && pwd)" +# When --local, use the repo's own setup.sh directly. +# Otherwise, find it inside the installed package. +SETUP_SH="" +if [ "$STUDIO_LOCAL_INSTALL" = true ] && [ -f "$_REPO_ROOT/studio/setup.sh" ]; then + SETUP_SH="$_REPO_ROOT/studio/setup.sh" +fi + +if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then + SETUP_SH=$("$VENV_DIR/bin/python" -c " +import importlib.resources +print(importlib.resources.files('studio') / 'setup.sh') +" 2>/dev/null || echo "") +fi + +# Fallback: search site-packages +if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then + SETUP_SH=$(find "$VENV_DIR" -path "*/studio/setup.sh" -print -quit 2>/dev/null || echo "") +fi + +if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then + echo "❌ ERROR: Could not find studio/setup.sh in the installed package." + exit 1 +fi + +# Ensure the venv's Python is on PATH so setup.sh can find it. +VENV_ABS_BIN="$(cd "$VENV_DIR/bin" && pwd)" if [ -n "$VENV_ABS_BIN" ]; then export PATH="$VENV_ABS_BIN:$PATH" fi -echo "==> Running unsloth studio setup..." -REQUESTED_PYTHON_VERSION="$(cd "$VENV_NAME/bin" && pwd)/python" \ -"$VENV_NAME/bin/unsloth" studio setup Running unsloth setup..." +if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + SKIP_STUDIO_BASE=1 \ + STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \ + STUDIO_LOCAL_INSTALL=1 \ + STUDIO_LOCAL_REPO="$_REPO_ROOT" \ + bash "$SETUP_SH" /dev/null; then + echo '' >> "$_SHELL_PROFILE" + echo '# Added by Unsloth installer' >> "$_SHELL_PROFILE" + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$_SHELL_PROFILE" + echo "==> Added ~/.local/bin to PATH in $_SHELL_PROFILE" + fi + fi + export PATH="$_LOCAL_BIN:$PATH" + ;; +esac create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS" @@ -694,8 +881,32 @@ echo " Unsloth Studio installed!" echo "=========================================" echo "" -echo " To launch, run:" -echo "" -echo " source ${VENV_NAME}/bin/activate" -echo " unsloth studio -H 0.0.0.0 -p 8888" -echo "" +# Launch studio automatically in interactive terminals; +# in non-interactive environments (Docker, CI, cloud-init) just print instructions. +if [ -t 1 ]; then + echo "==> Launching Unsloth Studio..." + echo "" + "$VENV_DIR/bin/unsloth" studio -H 0.0.0.0 -p 8888 + _LAUNCH_EXIT=$? + if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then + echo "" + echo "⚠️ Unsloth Studio failed to start after migration." + echo " Your migrated environment may be incompatible." + echo " To fix, remove the environment and reinstall:" + echo "" + echo " rm -rf $VENV_DIR" + echo " curl -fsSL https://unsloth.ai/install.sh | sh" + echo "" + fi + exit "$_LAUNCH_EXIT" +else + echo " To launch, run:" + echo "" + echo " unsloth studio -H 0.0.0.0 -p 8888" + echo "" + echo " Or activate the environment first:" + echo "" + echo " source ${VENV_DIR}/bin/activate" + echo " unsloth studio -H 0.0.0.0 -p 8888" + echo "" +fi diff --git a/studio/backend/colab.py b/studio/backend/colab.py index 25a9408ccb..ecf9fc2907 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -26,7 +26,7 @@ def _bootstrap_studio_venv() -> None: site-packages so that packages like structlog, fastapi, etc. are importable from notebook cells and take priority over system copies. """ - venv_lib = Path.home() / ".unsloth" / "studio" / ".venv" / "lib" + venv_lib = Path.home() / ".unsloth" / "studio" / "unsloth_studio" / "lib" if not venv_lib.exists(): import warnings diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 9b2678f478..39fec2e6f5 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -225,9 +225,20 @@ def _translate_pip_args_for_uv(args: tuple[str, ...]) -> list[str]: def _build_pip_cmd(args: tuple[str, ...]) -> list[str]: - """Build a standard pip install command.""" + """Build a standard pip install command. + + Strips uv-only flags like --upgrade-package that pip doesn't understand. + """ cmd = [sys.executable, "-m", "pip", "install"] - cmd.extend(args) + skip_next = False + for arg in args: + if skip_next: + skip_next = False + continue + if arg == "--upgrade-package": + skip_next = True # skip the flag and its value + continue + cmd.append(arg) return cmd @@ -241,7 +252,12 @@ def _build_uv_cmd(args: tuple[str, ...]) -> list[str]: # the system Python (observed on Colab and similar environments). cmd.extend(["--python", sys.executable]) cmd.extend(_translate_pip_args_for_uv(args)) - cmd.append("--torch-backend=auto") + # Torch is pre-installed by install.sh/setup.ps1. Do not add + # --torch-backend by default -- it can cause solver dead-ends on + # CPU-only machines. Callers that need it can set UV_TORCH_BACKEND. + _tb = os.environ.get("UV_TORCH_BACKEND", "") + if _tb: + cmd.append(f"--torch-backend={_tb}") return cmd @@ -325,22 +341,90 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None: def install_python_stack() -> int: global USE_UV, _STEP, _TOTAL _STEP = 0 - _TOTAL = 10 if IS_WINDOWS else 11 - # 1. Upgrade pip (needed even with uv as fallback and for bootstrapping) - _progress("pip upgrade") - run("Upgrading pip", [sys.executable, "-m", "pip", "install", "--upgrade", "pip"]) + # When called from install.sh (which already installed unsloth into the venv), + # SKIP_STUDIO_BASE=1 is set to avoid redundant reinstallation of base packages. + # When called from "unsloth studio update", it is NOT set so base packages + # (unsloth + unsloth-zoo) are always reinstalled to pick up new versions. + skip_base = os.environ.get("SKIP_STUDIO_BASE", "0") == "1" + # When --package is used, install a different package name (e.g. roland-sloth for testing) + package_name = os.environ.get("STUDIO_PACKAGE_NAME", "unsloth") + # When --local is used, overlay a local repo checkout after updating deps + local_repo = os.environ.get("STUDIO_LOCAL_REPO", "") + base_total = 10 if IS_WINDOWS else 11 + _TOTAL = (base_total - 1) if skip_base else base_total - # Try to use uv for faster installs + # 1. Try to use uv for faster installs (must happen before pip upgrade + # because uv venvs don't include pip by default) USE_UV = _bootstrap_uv() - # 2. Core packages: unsloth-zoo + unsloth - _progress("base packages") - pip_install( - "Installing base packages", - "--no-cache-dir", - req = REQ_ROOT / "base.txt", - ) + # 2. Ensure pip is available (uv venvs created by install.sh don't include pip) + _progress("pip bootstrap") + if USE_UV: + run( + "Bootstrapping pip via uv", + [ + "uv", + "pip", + "install", + "--python", + sys.executable, + "pip", + ], + ) + else: + run( + "Upgrading pip", + [sys.executable, "-m", "pip", "install", "--upgrade", "pip"], + ) + + # 3. Core packages: unsloth-zoo + unsloth (or custom package name) + if skip_base: + print(_green(f"✅ {package_name} already installed — skipping base packages")) + elif local_repo: + # Local dev install: update deps from base.txt, then overlay the + # local checkout as an editable install (--no-deps so torch is + # never re-resolved). + _progress("base packages") + pip_install( + "Updating base packages", + "--no-cache-dir", + "--upgrade-package", + "unsloth", + "--upgrade-package", + "unsloth-zoo", + req = REQ_ROOT / "base.txt", + ) + pip_install( + "Overlaying local repo (editable)", + "--no-cache-dir", + "--no-deps", + "-e", + local_repo, + constrain = False, + ) + elif package_name != "unsloth": + # Custom package name (e.g. roland-sloth for testing) — install directly + _progress("base packages") + pip_install( + f"Installing {package_name}", + "--no-cache-dir", + package_name, + ) + else: + # Update path: upgrade only unsloth + unsloth-zoo while preserving + # existing torch/CUDA installations. Torch is pre-installed by + # install.sh / setup.ps1; --upgrade-package targets only base pkgs. + _progress("base packages") + pip_install( + "Updating base packages", + "--no-cache-dir", + "--upgrade-package", + "unsloth", + "--upgrade-package", + "unsloth-zoo", + req = REQ_ROOT / "base.txt", + ) # 3. Extra dependencies _progress("unsloth extras") diff --git a/studio/setup.ps1 b/studio/setup.ps1 index d429958bf1..c58bcd5c8d 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -250,9 +250,15 @@ function Find-VsBuildTools { # ───────────────────────────────────────────── # Banner # ───────────────────────────────────────────── -Write-Host "+==============================================+" -ForegroundColor Green -Write-Host "| Unsloth Studio Setup (Windows) |" -ForegroundColor Green -Write-Host "+==============================================+" -ForegroundColor Green +if ($env:SKIP_STUDIO_BASE -eq "1") { + Write-Host "+==============================================+" -ForegroundColor Green + Write-Host "| Unsloth Studio Setup (Windows) |" -ForegroundColor Green + Write-Host "+==============================================+" -ForegroundColor Green +} else { + Write-Host "+==============================================+" -ForegroundColor Green + Write-Host "| Unsloth Studio Update (Windows) |" -ForegroundColor Green + Write-Host "+==============================================+" -ForegroundColor Green +} # ========================================================================== # PHASE 1: System-level prerequisites (winget installs, env vars) @@ -1075,9 +1081,9 @@ if (-not $PythonCmd) { Write-Host "[OK] Using $PythonCmd ($(& $PythonCmd --version 2>&1))" -ForegroundColor Green -# Always create a .venv for isolation -- even for pip installs. -# Created in the repo root (parent of studio/). -$VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv" +# The venv must already exist (created by install.ps1). +# This script (setup.ps1 / "unsloth studio update") only updates packages. +$VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio" # Stale-venv detection: if the venv exists but its torch flavor no longer # matches the current machine, wipe it so we get a clean install. @@ -1140,8 +1146,10 @@ if (Test-Path $VenvDir -PathType Container) { } if (-not (Test-Path $VenvDir)) { - Write-Host " Creating virtual environment at $VenvDir..." -ForegroundColor Cyan - & $PythonCmd -m venv $VenvDir + Write-Host "[ERROR] Virtual environment not found at $VenvDir" -ForegroundColor Red + Write-Host " Run install.ps1 first to create the environment:" -ForegroundColor Yellow + Write-Host " irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow + exit 1 } else { Write-Host " Reusing existing virtual environment at $VenvDir" -ForegroundColor Green } @@ -1582,8 +1590,9 @@ if ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { # Done # ============================================ Write-Host "" +$doneLine = if ($env:SKIP_STUDIO_BASE -eq "1") { "Setup Complete!" } else { "Update Complete!" } Write-Host "+===============================================+" -ForegroundColor Green -Write-Host "| Setup Complete! |" -ForegroundColor Green +Write-Host "| $doneLine |" -ForegroundColor Green Write-Host "| |" -ForegroundColor Green Write-Host "| Launch with: |" -ForegroundColor Green Write-Host "| unsloth studio -H 0.0.0.0 -p 8888 |" -ForegroundColor Green diff --git a/studio/setup.sh b/studio/setup.sh index 97bfbbfadc..0e99173755 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -44,9 +44,15 @@ run_quiet_no_exit() { _run_quiet return "$@" } -echo "╔══════════════════════════════════════╗" -echo "║ Unsloth Studio Setup Script ║" -echo "╚══════════════════════════════════════╝" +if [ "${SKIP_STUDIO_BASE:-0}" = "1" ]; then + echo "╔══════════════════════════════════════╗" + echo "║ Unsloth Studio Setup Script ║" + echo "╚══════════════════════════════════════╝" +else + echo "╔══════════════════════════════════════╗" + echo "║ Unsloth Studio Update Script ║" + echo "╚══════════════════════════════════════╝" +fi # ── Clean up stale Unsloth compiled caches ── rm -rf "$REPO_ROOT/unsloth_compiled_cache" @@ -244,114 +250,31 @@ fi # ── 6. Python venv + deps ── -# ── 6a. Discover best Python >= 3.11 and < 3.14 (i.e. 3.11.x, 3.12.x, or 3.13.x) ── -MIN_PY_MINOR=11 # minimum minor version (>= 3.11) -MAX_PY_MINOR=13 # maximum minor version (< 3.14) -BEST_PY="" -BEST_MINOR=0 - -# If the caller (e.g. install.sh) already chose a Python, use it directly. -if [ -n "${REQUESTED_PYTHON_VERSION:-}" ] && [ -x "$REQUESTED_PYTHON_VERSION" ]; then - _req_ver=$("$REQUESTED_PYTHON_VERSION" --version 2>&1 | awk '{print $2}') - _req_major=$(echo "$_req_ver" | cut -d. -f1) - _req_minor=$(echo "$_req_ver" | cut -d. -f2) - if [ "$_req_major" -eq 3 ] 2>/dev/null && \ - [ "$_req_minor" -ge "$MIN_PY_MINOR" ] 2>/dev/null && \ - [ "$_req_minor" -le "$MAX_PY_MINOR" ] 2>/dev/null; then - BEST_PY="$REQUESTED_PYTHON_VERSION" - echo "Using requested Python version: $BEST_PY" - else - echo "Ignoring requested Python $REQUESTED_PYTHON_VERSION ($_req_ver) -- outside supported range" - fi -fi - -if [ -z "$BEST_PY" ]; then -# Collect candidate python3 binaries (python3, python3.9, python3.10, …) -for candidate in $(compgen -c python3 2>/dev/null | grep -E '^python3(\.[0-9]+)?$' | sort -u); do - if ! command -v "$candidate" &>/dev/null; then - continue - fi - # Get version string, e.g. "Python 3.12.5" - ver_str=$("$candidate" --version 2>&1) || continue - ver_str=$(echo "$ver_str" | awk '{print $2}') - py_major=$(echo "$ver_str" | cut -d. -f1) - py_minor=$(echo "$ver_str" | cut -d. -f2) - - # Skip anything that isn't Python 3 - if [ "$py_major" -ne 3 ] 2>/dev/null; then - continue - fi - - # Skip versions below 3.11 - if [ "$py_minor" -lt "$MIN_PY_MINOR" ] 2>/dev/null; then - continue - fi - - # Skip versions above 3.13 (require < 3.14) - if [ "$py_minor" -gt "$MAX_PY_MINOR" ] 2>/dev/null; then - continue - fi - - # Keep the highest qualifying version - if [ "$py_minor" -gt "$BEST_MINOR" ]; then - BEST_PY="$candidate" - BEST_MINOR="$py_minor" - fi -done -fi - -if [ -z "$BEST_PY" ]; then - echo "❌ ERROR: No Python version between 3.${MIN_PY_MINOR} and 3.${MAX_PY_MINOR} found on this system." - echo " Detected Python 3 installations:" - for candidate in $(compgen -c python3 2>/dev/null | grep -E '^python3(\.[0-9]+)?$' | sort -u); do - if command -v "$candidate" &>/dev/null; then - echo " - $candidate ($($candidate --version 2>&1))" - fi - done - echo "" - echo " Please install Python 3.${MIN_PY_MINOR} or 3.${MAX_PY_MINOR}." - echo " For example: sudo apt install python3.12 python3.12-venv" - exit 1 -fi - -BEST_VER=$("$BEST_PY" --version 2>&1 | awk '{print $2}') -echo "✅ Using $BEST_PY ($BEST_VER) — compatible (3.${MIN_PY_MINOR}.x – 3.${MAX_PY_MINOR}.x)" - -REQ_ROOT="$SCRIPT_DIR/backend/requirements" -SINGLE_ENV_CONSTRAINTS="$REQ_ROOT/single-env/constraints.txt" -SINGLE_ENV_DATA_DESIGNER="$REQ_ROOT/single-env/data-designer.txt" -SINGLE_ENV_DATA_DESIGNER_DEPS="$REQ_ROOT/single-env/data-designer-deps.txt" -SINGLE_ENV_PATCH="$REQ_ROOT/single-env/patch_metadata.py" - -install_python_stack() { - python "$SCRIPT_DIR/install_python_stack.py" -} - -# Create venv under ~/.unsloth/studio/ (shared location, not in repo). -# All platforms (including Colab) use the same isolated venv so that -# studio dependencies are never installed into the system Python. +# The venv must already exist (created by install.sh). +# This script (setup.sh / "unsloth studio update") only updates packages. STUDIO_HOME="$HOME/.unsloth/studio" -VENV_DIR="$STUDIO_HOME/.venv" +VENV_DIR="$STUDIO_HOME/unsloth_studio" VENV_T5_DIR="$STUDIO_HOME/.venv_t5" -mkdir -p "$STUDIO_HOME" # Clean up legacy in-repo venvs if they exist [ -d "$REPO_ROOT/.venv" ] && rm -rf "$REPO_ROOT/.venv" [ -d "$REPO_ROOT/.venv_overlay" ] && rm -rf "$REPO_ROOT/.venv_overlay" [ -d "$REPO_ROOT/.venv_t5" ] && rm -rf "$REPO_ROOT/.venv_t5" +# Note: do NOT delete $STUDIO_HOME/.venv here — install.sh handles migration -rm -rf "$VENV_DIR" -rm -rf "$VENV_T5_DIR" -# Try creating venv with pip; fall back to --without-pip + bootstrap -# (some environments like Colab have broken ensurepip) -if ! "$BEST_PY" -m venv "$VENV_DIR" 2>/dev/null; then - "$BEST_PY" -m venv --without-pip "$VENV_DIR" - source "$VENV_DIR/bin/activate" - curl -sS https://bootstrap.pypa.io/get-pip.py | python > /dev/null -else - source "$VENV_DIR/bin/activate" +if [ ! -x "$VENV_DIR/bin/python" ]; then + echo "❌ ERROR: Virtual environment not found at $VENV_DIR" + echo " Run install.sh first to create the environment:" + echo " curl -fsSL https://unsloth.ai/install.sh | sh" + exit 1 fi +source "$VENV_DIR/bin/activate" + +install_python_stack() { + python "$SCRIPT_DIR/install_python_stack.py" +} + # ── Ensure uv is available (much faster than pip) ── USE_UV=false if command -v uv &>/dev/null; then @@ -370,22 +293,53 @@ fast_install() { } cd "$SCRIPT_DIR" -install_python_stack -# ── 6b. Pre-install transformers 5.x into .venv_t5/ ── -# Models like GLM-4.7-Flash need transformers>=5.3.0. Instead of pip-installing -# at runtime (slow, ~10-15s), we pre-install into a separate directory. -# The training subprocess just prepends .venv_t5/ to sys.path -- instant switch. -echo "" -echo " Pre-installing transformers 5.x for newer model support..." -mkdir -p "$VENV_T5_DIR" -run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.3.0" -run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.7.1" -run_quiet "install hf_xet for t5" fast_install --target "$VENV_T5_DIR" --no-deps "hf_xet==1.4.2" -# tiktoken is needed by Qwen-family tokenizers. Install with deps since -# regex/requests may be missing on Windows. -run_quiet "install tiktoken for t5" fast_install --target "$VENV_T5_DIR" "tiktoken" -echo "✅ Transformers 5.x pre-installed to $VENV_T5_DIR/" +# ── Check if Python deps need updating ── +# Compare installed package version against PyPI latest. +# Skip all Python dependency work if versions match (fast update path). +_PKG_NAME="${STUDIO_PACKAGE_NAME:-unsloth}" +_SKIP_PYTHON_DEPS=false +if [ "${SKIP_STUDIO_BASE:-0}" != "1" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then + # Only check when NOT called from install.sh (which just installed the package) + INSTALLED_VER=$("$VENV_DIR/bin/python" -c " +from importlib.metadata import version +print(version('$_PKG_NAME')) +" 2>/dev/null || echo "") + + LATEST_VER=$(curl -fsSL --max-time 5 "https://pypi.org/pypi/$_PKG_NAME/json" 2>/dev/null \ + | "$VENV_DIR/bin/python" -c "import sys,json; print(json.load(sys.stdin)['info']['version'])" 2>/dev/null \ + || echo "") + + if [ -n "$INSTALLED_VER" ] && [ -n "$LATEST_VER" ] && [ "$INSTALLED_VER" = "$LATEST_VER" ]; then + echo "✅ $_PKG_NAME $INSTALLED_VER is up to date (matches PyPI latest)" + _SKIP_PYTHON_DEPS=true + elif [ -n "$INSTALLED_VER" ] && [ -n "$LATEST_VER" ]; then + echo "⬆️ $_PKG_NAME $INSTALLED_VER → $LATEST_VER available, updating dependencies..." + elif [ -z "$LATEST_VER" ]; then + echo "⚠️ Could not reach PyPI, updating dependencies to be safe..." + fi +fi + +if [ "$_SKIP_PYTHON_DEPS" = false ]; then + install_python_stack + + # ── 6b. Pre-install transformers 5.x into .venv_t5/ ── + # Models like GLM-4.7-Flash need transformers>=5.3.0. Instead of pip-installing + # at runtime (slow, ~10-15s), we pre-install into a separate directory. + # The training subprocess just prepends .venv_t5/ to sys.path -- instant switch. + echo "" + echo " Pre-installing transformers 5.x for newer model support..." + mkdir -p "$VENV_T5_DIR" + run_quiet "install transformers 5.x" fast_install --target "$VENV_T5_DIR" --no-deps "transformers==5.3.0" + run_quiet "install huggingface_hub for t5" fast_install --target "$VENV_T5_DIR" --no-deps "huggingface_hub==1.7.1" + run_quiet "install hf_xet for t5" fast_install --target "$VENV_T5_DIR" --no-deps "hf_xet==1.4.2" + # tiktoken is needed by Qwen-family tokenizers. Install with deps since + # regex/requests may be missing on Windows. + run_quiet "install tiktoken for t5" fast_install --target "$VENV_T5_DIR" "tiktoken" + echo "✅ Transformers 5.x pre-installed to $VENV_T5_DIR/" +else + echo "✅ Python dependencies up to date — skipping" +fi # ── 7. WSL: pre-install GGUF build dependencies ── # On WSL, sudo requires a password and can't be entered during GGUF export @@ -651,9 +605,15 @@ rm -rf "$LLAMA_CPP_DIR" fi # end _SKIP_GGUF_BUILD check echo "" +if [ "${SKIP_STUDIO_BASE:-0}" = "1" ]; then + _DONE_LINE="║ Setup Complete! ║" +else + _DONE_LINE="║ Update Complete! ║" +fi + if [ "$IS_COLAB" = true ]; then echo "╔══════════════════════════════════════╗" - echo "║ Setup Complete! ║" + echo "$_DONE_LINE" echo "╠══════════════════════════════════════╣" echo "║ Unsloth Studio is ready to start ║" echo "║ in your Colab notebook! ║" @@ -663,7 +623,7 @@ if [ "$IS_COLAB" = true ]; then echo "╚══════════════════════════════════════╝" else echo "╔══════════════════════════════════════╗" - echo "║ Setup Complete! ║" + echo "$_DONE_LINE" echo "╠══════════════════════════════════════╣" echo "║ Launch with: ║" echo "║ ║" diff --git a/tests/python/__init__.py b/tests/python/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/python/test_cross_platform_parity.py b/tests/python/test_cross_platform_parity.py new file mode 100644 index 0000000000..6dd41be9fa --- /dev/null +++ b/tests/python/test_cross_platform_parity.py @@ -0,0 +1,137 @@ +"""Cross-platform parity tests between install.sh and install.ps1.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +INSTALL_SH = REPO_ROOT / "install.sh" +INSTALL_PS1 = REPO_ROOT / "install.ps1" + + +class TestNoTorchBackendAutoInInstallSh: + """install.sh primary install paths must not use --torch-backend=auto. + + The fallback else-branch (when TORCH_INDEX_URL is empty) is allowed to + use --torch-backend=auto since that is the last-resort recovery path. + """ + + def test_no_torch_backend_auto_outside_fallback(self): + lines = INSTALL_SH.read_text().splitlines() + # Find the fallback block: starts with the "else" after the + # TORCH_INDEX_URL check and ends at the next "fi". + fallback_start = None + fallback_end = None + for i, line in enumerate(lines): + if fallback_start is None and "GPU detection failed" in line: + fallback_start = i + elif ( + fallback_start is not None + and fallback_end is None + and line.strip() == "fi" + ): + fallback_end = i + break + fallback_range = ( + range(fallback_start or 0, (fallback_end or 0) + 1) + if fallback_start + else range(0) + ) + + matches = [ + (i + 1, line) + for i, line in enumerate(lines) + if "--torch-backend=auto" in line + and not line.lstrip().startswith("#") + and i not in fallback_range + ] + assert matches == [], ( + f"install.sh contains --torch-backend=auto outside the fallback block at lines: " + f"{[m[0] for m in matches]}" + ) + + def test_fallback_uses_torch_backend_auto(self): + """The fallback branch should use --torch-backend=auto as recovery.""" + text = INSTALL_SH.read_text() + assert ( + "GPU detection failed" in text + ), "install.sh should have a fallback branch for when GPU detection fails" + + +class TestInstallShHasGpuDetection: + """install.sh must contain the get_torch_index_url function.""" + + def test_function_exists(self): + text = INSTALL_SH.read_text() + assert ( + "get_torch_index_url()" in text + ), "install.sh is missing the get_torch_index_url() function" + + def test_torch_index_url_assigned(self): + text = INSTALL_SH.read_text() + assert ( + "TORCH_INDEX_URL=$(get_torch_index_url)" in text + ), "install.sh should assign TORCH_INDEX_URL from get_torch_index_url()" + + +class TestCudaMappingParity: + """CUDA version thresholds must match between install.sh and install.ps1.""" + + @staticmethod + def _extract_cuda_thresholds_sh(text: str) -> list[str]: + """Extract cu* suffixes from the major/minor comparison chain in install.sh.""" + # Only match lines in the if/elif chain that compare _major/_minor + in_func = False + results = [] + for line in text.splitlines(): + if "get_torch_index_url()" in line: + in_func = True + continue + if in_func and line.startswith("}"): + break + if in_func and ("_major" in line or "_minor" in line): + m = re.search(r"/(cu\d+|cpu)", line) + if m: + results.append(m.group(1)) + return results + + @staticmethod + def _extract_cuda_thresholds_ps1(text: str) -> list[str]: + """Extract cu* suffixes from the major/minor comparison chain in install.ps1.""" + in_func = False + depth = 0 + results = [] + for line in text.splitlines(): + if "function Get-TorchIndexUrl" in line: + in_func = True + depth = 1 + continue + if in_func: + depth += line.count("{") - line.count("}") + if depth <= 0: + break + # Only match the if-chain lines that compare $major/$minor + if "$major" in line or "$minor" in line: + m = re.search(r"/(cu\d+|cpu)", line) + if m: + results.append(m.group(1)) + return results + + def test_same_cuda_suffixes(self): + """Both scripts should produce the same ordered list of CUDA index suffixes.""" + sh_text = INSTALL_SH.read_text() + ps1_text = INSTALL_PS1.read_text() + + sh_thresholds = self._extract_cuda_thresholds_sh(sh_text) + ps1_thresholds = self._extract_cuda_thresholds_ps1(ps1_text) + + assert len(sh_thresholds) > 0, "Could not extract thresholds from install.sh" + assert len(ps1_thresholds) > 0, "Could not extract thresholds from install.ps1" + assert sh_thresholds == ps1_thresholds, ( + f"CUDA mapping mismatch:\n" + f" install.sh: {sh_thresholds}\n" + f" install.ps1: {ps1_thresholds}" + ) diff --git a/tests/python/test_install_python_stack.py b/tests/python/test_install_python_stack.py new file mode 100644 index 0000000000..16538ae42b --- /dev/null +++ b/tests/python/test_install_python_stack.py @@ -0,0 +1,56 @@ +"""Tests for install_python_stack._build_uv_cmd torch-backend handling.""" + +from __future__ import annotations + +import importlib +import os +import sys +from pathlib import Path +from unittest import mock + +import pytest + +# Add the studio directory so we can import install_python_stack +STUDIO_DIR = Path(__file__).resolve().parents[2] / "studio" +sys.path.insert(0, str(STUDIO_DIR)) + +# _build_uv_cmd lives at module level; import after path setup. +# We need to mock parts of the module that do work at import time. +import install_python_stack as ips + + +class TestBuildUvCmdTorchBackend: + """Verify _build_uv_cmd only adds --torch-backend when UV_TORCH_BACKEND is set.""" + + def _call(self, args: tuple[str, ...] = ()) -> list[str]: + return ips._build_uv_cmd(args) + + def test_default_no_torch_backend(self): + """Without UV_TORCH_BACKEND env var, no --torch-backend flag.""" + env = os.environ.copy() + env.pop("UV_TORCH_BACKEND", None) + with mock.patch.dict(os.environ, env, clear = True): + cmd = self._call(("somepackage",)) + assert not any( + a.startswith("--torch-backend") for a in cmd + ), f"--torch-backend should not appear by default, got: {cmd}" + + def test_uv_torch_backend_auto(self): + """UV_TORCH_BACKEND=auto adds --torch-backend=auto.""" + with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": "auto"}): + cmd = self._call(("somepackage",)) + assert "--torch-backend=auto" in cmd + + def test_uv_torch_backend_cpu(self): + """UV_TORCH_BACKEND=cpu adds --torch-backend=cpu.""" + with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": "cpu"}): + cmd = self._call(("somepackage",)) + assert "--torch-backend=cpu" in cmd + + def test_uv_torch_backend_empty(self): + """UV_TORCH_BACKEND="" (empty string) should NOT add --torch-backend.""" + with mock.patch.dict(os.environ, {"UV_TORCH_BACKEND": ""}): + cmd = self._call(("somepackage",)) + assert not any( + a.startswith("--torch-backend") for a in cmd + ), f"Empty UV_TORCH_BACKEND should not add flag, got: {cmd}" diff --git a/tests/run_all.sh b/tests/run_all.sh new file mode 100755 index 0000000000..d7fdb38e74 --- /dev/null +++ b/tests/run_all.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Run all installer tests. +set -e + +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "=== Bash tests ===" +sh "$TESTS_DIR/sh/test_get_torch_index_url.sh" + +echo "" +echo "=== Python tests ===" +python -m pytest "$TESTS_DIR/python/test_install_python_stack.py" -v +python -m pytest "$TESTS_DIR/python/test_cross_platform_parity.py" -v + +echo "" +echo "All tests passed." diff --git a/tests/sh/test_get_torch_index_url.sh b/tests/sh/test_get_torch_index_url.sh new file mode 100755 index 0000000000..6387922712 --- /dev/null +++ b/tests/sh/test_get_torch_index_url.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Unit tests for get_torch_index_url() from install.sh +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +PASS=0 +FAIL=0 + +# Extract only the get_torch_index_url function from install.sh +# Also replace the hardcoded /usr/bin/nvidia-smi fallback with a +# controllable path so we can test the "no GPU" scenario on GPU machines. +_FUNC_FILE=$(mktemp) +_FAKE_SMI_DIR=$(mktemp -d) +sed -n '/^get_torch_index_url()/,/^}/p' "$INSTALL_SH" \ + | sed "s|/usr/bin/nvidia-smi|$_FAKE_SMI_DIR/nvidia-smi-absent|g" \ + > "$_FUNC_FILE" + +# Save system PATH so we always have basic tools (uname, grep, head, etc.) +_SYS_PATH="/usr/local/bin:/usr/bin:/bin" + +assert_eq() { + _label="$1"; _expected="$2"; _actual="$3" + if [ "$_actual" = "$_expected" ]; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected '$_expected', got '$_actual')" + FAIL=$((FAIL + 1)) + fi +} + +# Helper: create a mock nvidia-smi that prints a given CUDA version string +make_mock_smi() { + _dir=$(mktemp -d) + cat > "$_dir/nvidia-smi" </dev/null || true) + [ -n "$_real" ] && ln -sf "$_real" "$_TOOLS_DIR/$_cmd" +done + +# Helper: run get_torch_index_url with a custom PATH +# $1 = directory with mock nvidia-smi (prepended to PATH), or "none" for no-GPU test +run_func() { + _mock_dir="$1" + if [ "$_mock_dir" = "none" ]; then + # Minimal PATH with only basic tools, no nvidia-smi anywhere + PATH="$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null + else + # Put mock nvidia-smi dir first, then basic tools + PATH="$_mock_dir:$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null + fi +} + +echo "=== test_get_torch_index_url ===" + +# 1) No nvidia-smi available -> cpu +_result=$(run_func "none") +assert_eq "no nvidia-smi -> cpu" "https://download.pytorch.org/whl/cpu" "$_result" + +# 2) CUDA 12.6 -> cu126 +_dir=$(make_mock_smi "12.6") +_result=$(run_func "$_dir") +assert_eq "CUDA 12.6 -> cu126" "https://download.pytorch.org/whl/cu126" "$_result" +rm -rf "$_dir" + +# 3) CUDA 12.8 -> cu128 +_dir=$(make_mock_smi "12.8") +_result=$(run_func "$_dir") +assert_eq "CUDA 12.8 -> cu128" "https://download.pytorch.org/whl/cu128" "$_result" +rm -rf "$_dir" + +# 4) CUDA 13.0 -> cu130 +_dir=$(make_mock_smi "13.0") +_result=$(run_func "$_dir") +assert_eq "CUDA 13.0 -> cu130" "https://download.pytorch.org/whl/cu130" "$_result" +rm -rf "$_dir" + +# 5) CUDA 12.4 -> cu124 +_dir=$(make_mock_smi "12.4") +_result=$(run_func "$_dir") +assert_eq "CUDA 12.4 -> cu124" "https://download.pytorch.org/whl/cu124" "$_result" +rm -rf "$_dir" + +# 6) CUDA 11.8 -> cu118 +_dir=$(make_mock_smi "11.8") +_result=$(run_func "$_dir") +assert_eq "CUDA 11.8 -> cu118" "https://download.pytorch.org/whl/cu118" "$_result" +rm -rf "$_dir" + +# 7) CUDA 10.2 (too old) -> cpu +_dir=$(make_mock_smi "10.2") +_result=$(run_func "$_dir") +assert_eq "CUDA 10.2 -> cpu" "https://download.pytorch.org/whl/cpu" "$_result" +rm -rf "$_dir" + +# 8) Unparseable nvidia-smi output -> cu126 default +_dir=$(mktemp -d) +cat > "$_dir/nvidia-smi" <<'MOCK' +#!/bin/sh +echo "something completely unexpected" +MOCK +chmod +x "$_dir/nvidia-smi" +_result=$(run_func "$_dir") +assert_eq "unparseable -> cu126" "https://download.pytorch.org/whl/cu126" "$_result" +rm -rf "$_dir" + +rm -f "$_FUNC_FILE" +rm -rf "$_FAKE_SMI_DIR" +rm -rf "$_TOOLS_DIR" + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/unsloth_cli/__init__.py b/unsloth_cli/__init__.py index 3b9043c5bf..3a821359b7 100644 --- a/unsloth_cli/__init__.py +++ b/unsloth_cli/__init__.py @@ -6,7 +6,6 @@ import typer from unsloth_cli.commands.train import train from unsloth_cli.commands.inference import inference from unsloth_cli.commands.export import export, list_checkpoints -from unsloth_cli.commands.ui import ui from unsloth_cli.commands.studio import studio_app app = typer.Typer( @@ -18,5 +17,4 @@ app.command()(train) app.command()(inference) app.command()(export) app.command("list-checkpoints")(list_checkpoints) -app.command()(ui) app.add_typer(studio_app, name = "studio", help = "Unsloth Studio commands.") diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index 192e138a9c..c6d398eebd 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -22,9 +22,9 @@ _PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent def _studio_venv_python() -> Optional[Path]: """Return the studio venv Python binary, or None if not set up.""" if platform.system() == "Windows": - p = STUDIO_HOME / ".venv" / "Scripts" / "python.exe" + p = STUDIO_HOME / "unsloth_studio" / "Scripts" / "python.exe" else: - p = STUDIO_HOME / ".venv" / "bin" / "python" + p = STUDIO_HOME / "unsloth_studio" / "bin" / "python" return p if p.is_file() else None @@ -44,7 +44,7 @@ def _find_run_py() -> Optional[Path]: "lib/python*/site-packages/studio/backend/run.py", "Lib/site-packages/studio/backend/run.py", ): - for match in (STUDIO_HOME / ".venv").glob(pattern): + for match in (STUDIO_HOME / "unsloth_studio").glob(pattern): return match return None @@ -64,7 +64,7 @@ def _find_setup_script() -> Optional[Path]: f"lib/python*/site-packages/studio/{name}", f"Lib/site-packages/studio/{name}", ): - for match in (STUDIO_HOME / ".venv").glob(pattern): + for match in (STUDIO_HOME / "unsloth_studio").glob(pattern): return match return None @@ -85,7 +85,7 @@ def studio_default( return # Always use the studio venv if it exists and we're not already in it - studio_venv_dir = STUDIO_HOME / ".venv" + studio_venv_dir = STUDIO_HOME / "unsloth_studio" in_studio_venv = sys.prefix.startswith(str(studio_venv_dir)) if not in_studio_venv: @@ -132,7 +132,7 @@ def studio_default( else: os.execvp(str(studio_python), args) else: - typer.echo("Studio not set up. Run 'unsloth studio setup' first.") + typer.echo("Studio not set up. Run install.sh first.") raise typer.Exit(1) from studio.backend.run import run_server @@ -166,12 +166,11 @@ def studio_default( typer.echo("\nShutting down...") -# ── unsloth studio setup ───────────────────────────────────────────── +# ── unsloth studio setup / update ───────────────────────────────────── -@studio_app.command() -def setup(): - """Run one-time Studio environment setup.""" +def _run_setup_script() -> None: + """Find and run the studio setup/update script.""" script = _find_setup_script() if not script: typer.echo("Error: Could not find setup script (setup.sh / setup.ps1).") @@ -188,6 +187,35 @@ def setup(): raise typer.Exit(result.returncode) +@studio_app.command(hidden = True) +def setup(): + """Deprecated: use 'unsloth studio update' or re-run install.sh.""" + typer.echo( + "Note: 'unsloth studio setup' is deprecated. Use 'unsloth studio update' or re-run install.sh." + ) + _run_setup_script() + + +@studio_app.command() +def update( + local: bool = typer.Option( + False, "--local", help = "Install from local repo instead of PyPI" + ), + package: str = typer.Option( + "unsloth", "--package", help = "Package name to install/update (for testing)" + ), +): + """Update Unsloth Studio dependencies and rebuild.""" + os.environ["STUDIO_LOCAL_INSTALL"] = "1" if local else "0" + os.environ["STUDIO_PACKAGE_NAME"] = package + if local: + # Pass the repo root explicitly so install_python_stack.py doesn't + # have to guess from SCRIPT_DIR (which may be inside site-packages). + repo_root = Path(__file__).resolve().parents[2] + os.environ["STUDIO_LOCAL_REPO"] = str(repo_root) + _run_setup_script() + + # ── unsloth studio reset-password ──────────────────────────────────── diff --git a/unsloth_cli/commands/ui.py b/unsloth_cli/commands/ui.py deleted file mode 100644 index 8f76636990..0000000000 --- a/unsloth_cli/commands/ui.py +++ /dev/null @@ -1,103 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 - -import os -import sys -import time -from pathlib import Path -from typing import Optional - -import typer - - -def ui( - port: int = typer.Option( - 8888, "--port", "-p", help = "Port to run the UI server on." - ), - host: str = typer.Option( - "0.0.0.0", "--host", "-H", help = "Host address to bind to." - ), - frontend: Optional[Path] = typer.Option( - None, "--frontend", "-f", help = "Path to frontend build directory." - ), - silent: bool = typer.Option( - False, "--silent", "-q", help = "Suppress startup messages." - ), -): - """Launch the Unsloth web UI backend server (alias for 'unsloth studio').""" - from unsloth_cli.commands.studio import ( - _studio_venv_python, - _find_run_py, - STUDIO_HOME, - ) - - # Re-execute in studio venv if available and not already inside it - studio_venv_dir = STUDIO_HOME / ".venv" - in_studio_venv = sys.prefix.startswith(str(studio_venv_dir)) - - if not in_studio_venv: - studio_python = _studio_venv_python() - run_py = _find_run_py() - if studio_python and run_py: - if not silent: - typer.echo("Launching Unsloth Studio... Please wait...") - args = [ - str(studio_python), - str(run_py), - "--host", - host, - "--port", - str(port), - ] - if frontend: - args.extend(["--frontend", str(frontend)]) - if silent: - args.append("--silent") - # On Windows, os.execvp() spawns a child but the parent lingers, - # so Ctrl+C only kills the parent leaving the child orphaned. - # Use subprocess.run() on Windows so the parent waits for the child. - if sys.platform == "win32": - import subprocess as _sp - - proc = _sp.Popen(args) - try: - rc = proc.wait() - except KeyboardInterrupt: - # Child has its own signal handler — let it finish - rc = proc.wait() - raise typer.Exit(rc) - else: - os.execvp(str(studio_python), args) - else: - typer.echo("Studio not set up. Run 'unsloth studio setup' first.") - raise typer.Exit(1) - - from studio.backend.run import run_server - - if not silent: - from studio.backend.run import _resolve_external_ip - - display_host = _resolve_external_ip() if host == "0.0.0.0" else host - typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}") - - run_kwargs = dict(host = host, port = port, silent = silent) - if frontend is not None: - run_kwargs["frontend_path"] = frontend - run_server(**run_kwargs) - - from studio.backend.run import _shutdown_event - - try: - if _shutdown_event is not None: - # NOTE: Event.wait() without a timeout blocks at the C level - # on Linux, preventing Python from delivering SIGINT (Ctrl+C). - while not _shutdown_event.is_set(): - _shutdown_event.wait(timeout = 1) - else: - while True: - time.sleep(1) - except KeyboardInterrupt: - from studio.backend.run import _graceful_shutdown, _server - - _graceful_shutdown(_server) - typer.echo("\nShutting down...") From cc1be75621c17d023d04c6334dff143ee2ad5e84 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:32:31 +0000 Subject: [PATCH 05/94] studio: stabilize reasoning panel scroll behavior and prevent composer overlap (#4587) * fix(studio): reasoning panel scroll and thread footer overlap * refactor(studio): dedupe reasoning scroll lock teardown --- .../src/components/assistant-ui/reasoning.tsx | 73 +++++++++++++++++-- .../src/components/assistant-ui/thread.tsx | 2 +- .../src/components/ui/collapsible.tsx | 15 ++-- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx index 0e37f6d433..6b2c7a05e7 100644 --- a/studio/frontend/src/components/assistant-ui/reasoning.tsx +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -17,7 +17,6 @@ import { type ReasoningGroupComponent, type ReasoningMessagePartComponent, useAuiState, - useScrollLock, } from "@assistant-ui/react"; import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { Idea01Icon } from "@hugeicons/core-free-icons"; @@ -34,6 +33,7 @@ import { useState, } from "react"; const ANIMATION_DURATION = 200; +const AUTO_SCROLL_THRESHOLD_PX = 24; export const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", { variants: { @@ -68,8 +68,49 @@ function ReasoningRoot({ ...props }: ReasoningRootProps) { const collapsibleRef = useRef(null); + const lockCleanupRef = useRef<(() => void) | null>(null); const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + useEffect(() => { + return () => { + lockCleanupRef.current?.(); + }; + }, []); + + const lockScroll = useCallback(() => { + lockCleanupRef.current?.(); + + const animatedElement = collapsibleRef.current; + if (!animatedElement) return; + + let scrollContainer: HTMLElement | null = animatedElement; + while (scrollContainer) { + const { overflowY } = getComputedStyle(scrollContainer); + if (overflowY === "scroll" || overflowY === "auto") { + break; + } + scrollContainer = scrollContainer.parentElement; + } + if (!scrollContainer) return; + + const scrollPosition = scrollContainer.scrollTop; + const resetPosition = () => { + scrollContainer.scrollTop = scrollPosition; + }; + + scrollContainer.addEventListener("scroll", resetPosition); + let timeoutId: ReturnType | null = null; + const cleanup = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + scrollContainer.removeEventListener("scroll", resetPosition); + lockCleanupRef.current = null; + }; + timeoutId = setTimeout(cleanup, ANIMATION_DURATION); + lockCleanupRef.current = cleanup; + }, []); const isControlled = controlledOpen !== undefined; const isOpen = isControlled ? controlledOpen : uncontrolledOpen; @@ -220,6 +261,8 @@ function ReasoningText({ }: ComponentProps<"div"> & { streaming?: boolean }) { const scrollRef = useRef(null); const shouldAutoScrollRef = useRef(true); + const detachedFromBottomRef = useRef(false); + const lastScrollTopRef = useRef(0); useEffect(() => { if (!(streaming && scrollRef.current)) { @@ -227,8 +270,25 @@ function ReasoningText({ } const el = scrollRef.current; const updateAutoScroll = () => { + const currentScrollTop = el.scrollTop; + if (currentScrollTop < lastScrollTopRef.current) { + detachedFromBottomRef.current = true; + } const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - shouldAutoScrollRef.current = distanceFromBottom <= 24; + if ( + detachedFromBottomRef.current && + distanceFromBottom <= AUTO_SCROLL_THRESHOLD_PX + ) { + detachedFromBottomRef.current = false; + } + shouldAutoScrollRef.current = !detachedFromBottomRef.current; + lastScrollTopRef.current = currentScrollTop; + }; + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0) { + detachedFromBottomRef.current = true; + shouldAutoScrollRef.current = false; + } }; const observer = new MutationObserver(() => { if (shouldAutoScrollRef.current) { @@ -236,16 +296,19 @@ function ReasoningText({ } }); el.addEventListener("scroll", updateAutoScroll); + el.addEventListener("wheel", handleWheel, { passive: true }); observer.observe(el, { childList: true, subtree: true, characterData: true, }); - shouldAutoScrollRef.current = true; - el.scrollTop = el.scrollHeight; + lastScrollTopRef.current = el.scrollTop; + detachedFromBottomRef.current = false; + updateAutoScroll(); return () => { observer.disconnect(); el.removeEventListener("scroll", updateAutoScroll); + el.removeEventListener("wheel", handleWheel); }; }, [streaming]); diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 0c07e133fb..d688822815 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -89,7 +89,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ }} /> - + !thread.isEmpty}> {!hideComposer && } diff --git a/studio/frontend/src/components/ui/collapsible.tsx b/studio/frontend/src/components/ui/collapsible.tsx index 3566eb9859..df5347c1a7 100644 --- a/studio/frontend/src/components/ui/collapsible.tsx +++ b/studio/frontend/src/components/ui/collapsible.tsx @@ -2,13 +2,18 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { cn } from "@/lib/utils"; +import * as React from "react"; import { Collapsible as CollapsiblePrimitive } from "radix-ui"; -function Collapsible({ - ...props -}: React.ComponentProps) { - return ; -} +const Collapsible = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ ...props }, ref) => { + return ( + + ); +}); +Collapsible.displayName = CollapsiblePrimitive.Root.displayName; function CollapsibleTrigger({ ...props From f4d8a246bf4454f23e73dca095ae30802ccc9c8a Mon Sep 17 00:00:00 2001 From: DoubleMathew Date: Wed, 25 Mar 2026 07:42:43 -0500 Subject: [PATCH 06/94] Use prebuilt llama.cpp for unsloth studio setup (#4562) * Use prebuilt llama.cpp for unsloth studio setup * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix 3 issues that cause unnecessary fallback to source build 1. Make filelock import optional -- environments without filelock (e.g. minimal installs) crashed at import time instead of gracefully skipping the lock. 2. Use already-verified converter script from the hydrated source tree instead of re-downloading from raw.githubusercontent.com with no checksum. Adds symlink with copy fallback for the legacy filename. 3. Initialize $SkipPrebuiltInstall in setup.ps1 before first use to prevent potential uninitialized variable errors. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep network fallback in ensure_converter_scripts Prefer the local verified copy from the hydrated source tree, but retain the original network download as a fallback if the file is missing. Create the legacy hyphenated filename as a symlink with a copy fallback instead of writing a second full copy. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix 4 bugs in source-build fallback and binary_env paths - setup.ps1: Replace git pull + checkout FETCH_HEAD with fetch + checkout -B to avoid detached HEAD state that breaks re-runs. Use pinned tag in both fetch and clone paths. - setup.sh: Move rm -rf after cmake/git prerequisite checks so a missing tool no longer deletes the existing install. Add --branch tag to clone. - install_llama_prebuilt.py: Add binary_path.parent to Linux LD_LIBRARY_PATH in binary_env() so bundled .so files in build/bin are found even without RPATH, matching the existing Windows PATH logic. - Add test for binary_env LD_LIBRARY_PATH on Linux. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle unresolved "latest" tag in source-build fallback clone When tag resolution fails and the requested tag is "latest", both setup scripts now omit --branch from git clone so the default branch is cloned instead of failing on a nonexistent "latest" branch/tag. Similarly, the PS1 fetch path fetches the default ref when the tag is "latest". * Resolve actual latest ggml-org tag instead of using literal "latest" When both Python tag resolution attempts fail and the requested tag is "latest", query the GitHub API for the actual latest release tag from ggml-org/llama.cpp (e.g. b8508) instead of passing the literal string "latest" to git clone --branch, which would fail since no such branch/tag exists. setup.sh uses curl + python json parsing; setup.ps1 uses Invoke-RestMethod. Both fall back to the raw requested tag if the API call also fails. * Try Unsloth release repo before ggml-org when resolving latest tag When falling back to the GitHub API to resolve "latest", query the Unsloth release repo (unslothai/llama.cpp) first since it has the prebuilt binaries pinned to tested tags. Only fall back to ggml-org/llama.cpp if the Unsloth repo query fails. * Add comprehensive sandbox tests for PR #4562 bug fixes 35 tests covering all fixes across platforms: - binary_env cross-platform (Linux LD_LIBRARY_PATH, Windows PATH, macOS DYLD_LIBRARY_PATH) with edge cases (dedup, ordering, existing paths) - resolve_requested_llama_tag (concrete, latest, None, empty) - setup.sh logic via subprocess: prereq check ordering (cmake/git missing preserves install), pinned tag in clone, fetch+checkout -B pattern, fetch failure warns instead of aborting - "latest" tag resolution fallback chain (Unsloth API -> ggml-org -> raw) with mock curl: success, failure, malformed JSON, empty body, empty tag_name, env overrides - Source code pattern verification for both .sh and .ps1 files All 138 tests pass in isolated uv venv. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add binary_path.parent to macOS DYLD_LIBRARY_PATH in binary_env macOS prebuilt .dylib files are overlaid into build/bin (same as Linux), but binary_env only added install_dir to DYLD_LIBRARY_PATH. Add binary_path.parent so the loader can find sibling dylibs even without embedded loader paths. Mirrors the existing fix for Linux LD_LIBRARY_PATH and the Windows PATH pattern. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard --branch when resolved tag is "latest"; fix broken test assertion When all API fallbacks fail and the tag stays as literal "latest", omit --branch from git clone (clones default branch instead of failing). Both setup.sh and setup.ps1 now check for "latest" before passing --branch to git clone/fetch. Also fix test_setup_ps1_clone_uses_branch_tag which used Python tuple syntax (assert "x", "y" in z) that always passes. Changed to assert "x" in z and "y" in z. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix macOS DYLD trailing colon, install_lock no-op, and debug log - binary_env macOS: use dedupe_existing_dirs instead of raw string concatenation. Eliminates trailing colon in DYLD_LIBRARY_PATH (which causes dyld to search CWD for libraries) and deduplicates when binary_path.parent == install_dir. Now consistent with the Linux and Windows branches. - install_lock: when filelock is not installed, use os.O_CREAT|O_EXCL as a fallback exclusive file lock with timeout, instead of yielding with no locking. Prevents concurrent installs from corrupting each other's staging directories. - setup.ps1: remove [DEBUG] log line that printed to every user on every Windows setup run. * Add stale-lock detection and atomic clone-then-swap install_lock fallback (no filelock): write PID to lock file and check if the holder process is still alive on contention. Dead PIDs (ProcessLookupError) and unreadable lock files trigger immediate cleanup. Live processes owned by other users (PermissionError) are correctly recognized as alive -- the lock is not removed. setup.sh/setup.ps1 source-build: clone into a temporary directory first, then swap into place only on success. If git clone fails, the existing install is preserved instead of being deleted by the premature rm -rf. * Remove redundant upstream_tag != release_tag check load_approved_release_checksums compared checksums.upstream_tag against the Unsloth release_tag, which are different namespaces (upstream ggml-org tag vs Unsloth published tag). This only worked because both happened to be "b8508" by convention. Would break if Unsloth ever uses a different release naming scheme. The existing check at parse_approved_release_checksums (line 950) already validates the release_tag field correctly. * Fix lock TOCTOU race and build-in-temp-dir swap install_lock fallback: add os.fsync(fd) after writing PID to ensure the PID is visible to racing processes before they check. Treat empty lock files (PID not yet written) as "wait and retry" instead of stale, closing the window where two processes could both see an empty file, both unlink it, and both acquire the lock. setup.sh/setup.ps1 source-build: clone AND build in a temp directory (LLAMA_CPP_DIR.build.$$). Only swap into the final LLAMA_CPP_DIR after the build succeeds. If clone or cmake or build fails, the temp dir is cleaned up and the existing working install is preserved. Previously, rm -rf ran after clone but before build, destroying the existing install even if the build later failed. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/install_llama_prebuilt.py | 3395 +++++++++++++++++ studio/setup.ps1 | 226 +- studio/setup.sh | 127 +- .../install/smoke_test_llama_prebuilt.py | 142 + .../test_install_llama_prebuilt_logic.py | 630 +++ tests/studio/install/test_pr4562_bugfixes.py | 687 ++++ tests/studio/install/test_selection_logic.py | 903 +++++ 7 files changed, 6046 insertions(+), 64 deletions(-) create mode 100755 studio/install_llama_prebuilt.py create mode 100644 tests/studio/install/smoke_test_llama_prebuilt.py create mode 100644 tests/studio/install/test_install_llama_prebuilt_logic.py create mode 100644 tests/studio/install/test_pr4562_bugfixes.py create mode 100644 tests/studio/install/test_selection_logic.py diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py new file mode 100755 index 0000000000..a9d0b72352 --- /dev/null +++ b/studio/install_llama_prebuilt.py @@ -0,0 +1,3395 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Cross platform llama.cpp prebuilt installer for Unsloth Studio""" + +from __future__ import annotations + +import argparse +import fnmatch +import hashlib +import json +import os +import platform +import random +import shutil +import site +import socket +import subprocess +import sys +import tarfile +import tempfile +import textwrap +import time +import urllib.error +import urllib.parse +import urllib.request +import zipfile +from contextlib import contextmanager +from dataclasses import dataclass + +try: + from filelock import FileLock, Timeout as FileLockTimeout +except ImportError: + FileLock = None + FileLockTimeout = None +from pathlib import Path +from typing import Any, Iterable, Iterator + + +EXIT_SUCCESS = 0 +EXIT_FALLBACK = 2 +EXIT_ERROR = 1 + +APPROVED_PREBUILT_LLAMA_TAG = "b8508" +DEFAULT_LLAMA_TAG = os.environ.get("UNSLOTH_LLAMA_TAG", APPROVED_PREBUILT_LLAMA_TAG) +DEFAULT_PUBLISHED_REPO = os.environ.get( + "UNSLOTH_LLAMA_RELEASE_REPO", "unslothai/llama.cpp" +) +DEFAULT_PUBLISHED_TAG = os.environ.get("UNSLOTH_LLAMA_RELEASE_TAG") +DEFAULT_PUBLISHED_MANIFEST_ASSET = os.environ.get( + "UNSLOTH_LLAMA_RELEASE_MANIFEST_ASSET", "llama-prebuilt-manifest.json" +) +DEFAULT_PUBLISHED_SHA256_ASSET = os.environ.get( + "UNSLOTH_LLAMA_RELEASE_SHA256_ASSET", "llama-prebuilt-sha256.json" +) +UPSTREAM_REPO = "ggml-org/llama.cpp" +UPSTREAM_RELEASES_API = f"https://api.github.com/repos/{UPSTREAM_REPO}/releases/latest" +TEST_MODEL_URL = ( + "https://huggingface.co/ggml-org/models/resolve/main/tinyllamas/stories260K.gguf" +) +TEST_MODEL_SHA256 = "270cba1bd5109f42d03350f60406024560464db173c0e387d91f0426d3bd256d" +VALIDATION_MODEL_CACHE_DIRNAME = ".cache" +VALIDATION_MODEL_CACHE_FILENAME = "stories260K.gguf" +INSTALL_LOCK_TIMEOUT_SECONDS = 300 +INSTALL_STAGING_ROOT_NAME = ".staging" +GITHUB_AUTH_HOSTS = {"api.github.com", "github.com"} +RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504} +HTTP_FETCH_ATTEMPTS = 4 +HTTP_FETCH_BASE_DELAY_SECONDS = 0.75 +SERVER_PORT_BIND_ATTEMPTS = 3 +SERVER_BIND_RETRY_WINDOW_SECONDS = 5.0 +TTY_PROGRESS_START_DELAY_SECONDS = 0.5 + + +@dataclass +class HostInfo: + system: str + machine: str + is_windows: bool + is_linux: bool + is_macos: bool + is_x86_64: bool + is_arm64: bool + nvidia_smi: str | None + driver_cuda_version: tuple[int, int] | None + compute_caps: list[str] + visible_cuda_devices: str | None + has_physical_nvidia: bool + has_usable_nvidia: bool + + +@dataclass +class AssetChoice: + repo: str + tag: str + name: str + url: str + source_label: str + runtime_name: str | None = None + runtime_url: str | None = None + is_ready_bundle: bool = False + install_kind: str = "" + bundle_profile: str | None = None + runtime_line: str | None = None + coverage_class: str | None = None + supported_sms: list[str] | None = None + min_sm: int | None = None + max_sm: int | None = None + selection_log: list[str] | None = None + expected_sha256: str | None = None + + +@dataclass(frozen = True) +class PublishedLlamaArtifact: + asset_name: str + install_kind: str + runtime_line: str | None + coverage_class: str | None + supported_sms: list[str] + min_sm: int | None + max_sm: int | None + bundle_profile: str | None + rank: int + + +@dataclass +class PublishedReleaseBundle: + repo: str + release_tag: str + upstream_tag: str + assets: dict[str, str] + manifest_asset_name: str + artifacts: list[PublishedLlamaArtifact] + selection_log: list[str] + + +@dataclass +class LinuxCudaSelection: + attempts: list[AssetChoice] + selection_log: list[str] + + @property + def primary(self) -> AssetChoice: + if not self.attempts: + raise RuntimeError("linux CUDA selection unexpectedly had no attempts") + return self.attempts[0] + + +@dataclass +class CudaRuntimePreference: + runtime_line: str | None + selection_log: list[str] + + +@dataclass(frozen = True) +class ApprovedArtifactHash: + asset_name: str + sha256: str + repo: str | None + kind: str | None + + +@dataclass +class ApprovedReleaseChecksums: + repo: str + release_tag: str + upstream_tag: str + source_commit: str | None + artifacts: dict[str, ApprovedArtifactHash] + + +class PrebuiltFallback(RuntimeError): + pass + + +def log(message: str) -> None: + print(f"[llama-prebuilt] {message}") + + +def log_lines(lines: Iterable[str]) -> None: + for line in lines: + log(line) + + +def parsed_hostname(url: str | None) -> str | None: + if not url: + return None + try: + hostname = urllib.parse.urlparse(url).hostname + except Exception: + return None + if not hostname: + return None + return hostname.lower() + + +def should_send_github_auth(url: str | None) -> bool: + return parsed_hostname(url) in GITHUB_AUTH_HOSTS + + +def auth_headers(url: str | None = None) -> dict[str, str]: + headers = { + "User-Agent": "unsloth-studio-llama-prebuilt", + } + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if token and should_send_github_auth(url): + headers["Authorization"] = f"Bearer {token}" + return headers + + +def github_api_headers(url: str | None = None) -> dict[str, str]: + return { + "Accept": "application/vnd.github+json", + **auth_headers(url), + } + + +def is_github_api_url(url: str | None) -> bool: + return parsed_hostname(url) == "api.github.com" + + +def is_retryable_url_error(exc: Exception) -> bool: + if isinstance(exc, urllib.error.HTTPError): + return exc.code in RETRYABLE_HTTP_STATUS + if isinstance(exc, urllib.error.URLError): + return True + if isinstance(exc, TimeoutError): + return True + if isinstance(exc, socket.timeout): + return True + return False + + +def sleep_backoff( + attempt: int, *, base_delay: float = HTTP_FETCH_BASE_DELAY_SECONDS +) -> None: + delay = base_delay * (2 ** max(attempt - 1, 0)) + delay += random.uniform(0.0, 0.2) + time.sleep(delay) + + +def atomic_write_bytes(destination: Path, data: bytes) -> None: + destination.parent.mkdir(parents = True, exist_ok = True) + with tempfile.NamedTemporaryFile( + prefix = destination.name + ".tmp-", + dir = destination.parent, + delete = False, + ) as handle: + tmp_path = Path(handle.name) + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, destination) + + +def atomic_replace_from_tempfile(tmp_path: Path, destination: Path) -> None: + destination.parent.mkdir(parents = True, exist_ok = True) + os.replace(tmp_path, destination) + + +def source_archive_logical_name(upstream_tag: str) -> str: + return f"llama.cpp-source-{upstream_tag}.tar.gz" + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def normalize_sha256_digest(value: str | None) -> str | None: + if not isinstance(value, str) or not value: + return None + lowered = value.lower() + if lowered.startswith("sha256:"): + lowered = lowered.split(":", 1)[1] + if len(lowered) != 64 or any(ch not in "0123456789abcdef" for ch in lowered): + return None + return lowered + + +def format_byte_count(num_bytes: float) -> str: + units = ["B", "KiB", "MiB", "GiB", "TiB"] + value = float(num_bytes) + for unit in units: + if abs(value) < 1024.0 or unit == units[-1]: + if unit == "B": + return f"{int(value)} {unit}" + return f"{value:.1f} {unit}" + value /= 1024.0 + return f"{num_bytes:.1f} B" + + +class DownloadProgress: + def __init__(self, label: str, total_bytes: int | None) -> None: + self.label = label + self.total_bytes = total_bytes if total_bytes and total_bytes > 0 else None + self.start_time = time.monotonic() + self.last_emit = 0.0 + term_ok = os.environ.get("TERM", "").lower() != "dumb" + self.stream = ( + sys.stderr + if sys.stderr.isatty() + else sys.stdout + if sys.stdout.isatty() + else sys.stderr + ) + self.is_tty = term_ok and self.stream.isatty() + self.completed = False + self.last_milestone_percent = -1 + self.last_milestone_bytes = 0 + self.has_rendered_tty_progress = False + + def _render(self, downloaded_bytes: int, *, final: bool = False) -> str: + elapsed = max(time.monotonic() - self.start_time, 1e-6) + speed = downloaded_bytes / elapsed + speed_text = f"{format_byte_count(speed)}/s" + if self.total_bytes is not None: + percent = min(100.0, (downloaded_bytes / self.total_bytes) * 100.0) + return ( + f"{self.label}: {percent:5.1f}% " + f"({format_byte_count(downloaded_bytes)}/{format_byte_count(self.total_bytes)}) " + f"at {speed_text}" + ) + if final: + return f"{self.label}: {format_byte_count(downloaded_bytes)} downloaded at {speed_text}" + return f"{self.label}: {format_byte_count(downloaded_bytes)} downloaded at {speed_text}" + + def update(self, downloaded_bytes: int) -> None: + now = time.monotonic() + if self.is_tty: + elapsed = now - self.start_time + if not self.has_rendered_tty_progress: + if ( + self.total_bytes is not None + and downloaded_bytes >= self.total_bytes + ): + return + if elapsed < TTY_PROGRESS_START_DELAY_SECONDS: + return + min_interval = 0.2 + if ( + self.has_rendered_tty_progress + and not self.completed + and (now - self.last_emit) < min_interval + ): + return + self.last_emit = now + line = self._render(downloaded_bytes) + self.stream.write("\r\033[K" + line) + self.stream.flush() + self.has_rendered_tty_progress = True + return + + should_emit = False + if self.total_bytes is not None: + percent = int((downloaded_bytes * 100) / max(self.total_bytes, 1)) + milestone_percent = min((percent // 25) * 25, 100) + if ( + milestone_percent > self.last_milestone_percent + and milestone_percent < 100 + ): + self.last_milestone_percent = milestone_percent + should_emit = True + else: + byte_step = 25 * 1024 * 1024 + if ( + downloaded_bytes - self.last_milestone_bytes >= byte_step + and (now - self.last_emit) >= 5.0 + ): + self.last_milestone_bytes = downloaded_bytes + should_emit = True + + if not should_emit: + return + + self.last_emit = now + self.stream.write(self._render(downloaded_bytes) + "\n") + self.stream.flush() + + def finish(self, downloaded_bytes: int) -> None: + self.completed = True + line = self._render(downloaded_bytes, final = True) + if self.is_tty: + if not self.has_rendered_tty_progress: + return + self.stream.write("\r\033[K") + else: + self.stream.write(line + "\n") + self.stream.flush() + + +def download_label_from_url(url: str) -> str: + name = Path(urllib.parse.urlparse(url).path).name + return name or url + + +def download_bytes( + url: str, + *, + timeout: int = 120, + attempts: int = HTTP_FETCH_ATTEMPTS, + headers: dict[str, str] | None = None, + progress_label: str | None = None, +) -> bytes: + last_exc: Exception | None = None + for attempt in range(1, attempts + 1): + try: + request = urllib.request.Request(url, headers = headers or auth_headers(url)) + with urllib.request.urlopen(request, timeout = timeout) as response: + total_bytes: int | None = None + content_length = response.headers.get("Content-Length") + if content_length and content_length.isdigit(): + total_bytes = int(content_length) + progress = ( + DownloadProgress(progress_label, total_bytes) + if progress_label + else None + ) + data = bytearray() + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + data.extend(chunk) + if progress is not None: + progress.update(len(data)) + if progress is not None: + progress.finish(len(data)) + return bytes(data) + except Exception as exc: + last_exc = exc + if attempt >= attempts or not is_retryable_url_error(exc): + raise + log(f"fetch failed ({attempt}/{attempts}) for {url}: {exc}; retrying") + sleep_backoff(attempt) + assert last_exc is not None + raise last_exc + + +def fetch_json(url: str) -> Any: + data = download_bytes( + url, + timeout = 30, + headers = github_api_headers(url) + if is_github_api_url(url) + else auth_headers(url), + ) + if not data: + raise RuntimeError(f"downloaded empty JSON payload from {url}") + try: + payload = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError(f"downloaded invalid JSON from {url}: {exc}") from exc + if not isinstance(payload, dict) and not isinstance(payload, list): + raise RuntimeError( + f"downloaded unexpected JSON type from {url}: {type(payload).__name__}" + ) + return payload + + +def download_file(url: str, destination: Path) -> None: + destination.parent.mkdir(parents = True, exist_ok = True) + last_exc: Exception | None = None + for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1): + tmp_path: Path | None = None + try: + request = urllib.request.Request(url, headers = auth_headers(url)) + with tempfile.NamedTemporaryFile( + prefix = destination.name + ".tmp-", + dir = destination.parent, + delete = False, + ) as handle: + tmp_path = Path(handle.name) + with urllib.request.urlopen(request, timeout = 120) as response: + total_bytes: int | None = None + content_length = response.headers.get("Content-Length") + if content_length and content_length.isdigit(): + total_bytes = int(content_length) + progress = DownloadProgress( + f"Downloading {destination.name}", total_bytes + ) + downloaded_bytes = 0 + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + handle.write(chunk) + downloaded_bytes += len(chunk) + progress.update(downloaded_bytes) + progress.finish(downloaded_bytes) + handle.flush() + os.fsync(handle.fileno()) + if not tmp_path.exists() or tmp_path.stat().st_size == 0: + raise RuntimeError(f"downloaded empty file from {url}") + atomic_replace_from_tempfile(tmp_path, destination) + return + except Exception as exc: + last_exc = exc + if tmp_path is not None: + try: + tmp_path.unlink(missing_ok = True) + except Exception: + pass + if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc): + raise + log( + f"download failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying" + ) + sleep_backoff(attempt) + assert last_exc is not None + raise last_exc + + +def download_file_verified( + url: str, + destination: Path, + *, + expected_sha256: str, + label: str, +) -> None: + normalized_expected = normalize_sha256_digest(expected_sha256) + if not normalized_expected: + raise PrebuiltFallback(f"{label} did not have a valid approved sha256") + + for attempt in range(1, 3): + download_file(url, destination) + actual_sha256 = sha256_file(destination) + if actual_sha256 == normalized_expected: + log(f"verified {label} sha256={actual_sha256}") + return + + log( + f"{label} checksum mismatch on attempt {attempt}/2: " + f"expected={normalized_expected} actual={actual_sha256}" + ) + destination.unlink(missing_ok = True) + if attempt == 2: + raise PrebuiltFallback( + f"{label} checksum mismatch after retry: expected={normalized_expected} actual={actual_sha256}" + ) + log(f"retrying {label} download after checksum mismatch") + + +def upstream_source_archive_urls(tag: str) -> list[str]: + encoded_tag = urllib.parse.quote(tag, safe = "") + return [ + f"https://codeload.github.com/{UPSTREAM_REPO}/tar.gz/refs/tags/{encoded_tag}", + f"https://github.com/{UPSTREAM_REPO}/archive/refs/tags/{encoded_tag}.tar.gz", + ] + + +def github_release_assets(repo: str, tag: str) -> dict[str, str]: + payload = fetch_json( + f"https://api.github.com/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe = '')}" + ) + if not isinstance(payload, dict): + raise RuntimeError(f"unexpected release payload for {repo}@{tag}") + return release_asset_map(payload) + + +def github_release(repo: str, tag: str) -> dict[str, Any]: + payload = fetch_json( + f"https://api.github.com/repos/{repo}/releases/tags/{urllib.parse.quote(tag, safe = '')}" + ) + if not isinstance(payload, dict): + raise RuntimeError(f"unexpected release payload for {repo}@{tag}") + return payload + + +def github_releases(repo: str, *, per_page: int = 100) -> list[dict[str, Any]]: + releases: list[dict[str, Any]] = [] + page = 1 + while True: + payload = fetch_json( + f"https://api.github.com/repos/{repo}/releases?per_page={per_page}&page={page}" + ) + if not isinstance(payload, list): + raise RuntimeError(f"unexpected releases payload for {repo}") + page_items = [item for item in payload if isinstance(item, dict)] + releases.extend(page_items) + if len(payload) < per_page: + break + page += 1 + return releases + + +def latest_upstream_release_tag() -> str: + payload = fetch_json(UPSTREAM_RELEASES_API) + tag = payload.get("tag_name") + if not isinstance(tag, str) or not tag: + raise RuntimeError( + f"latest release tag was missing from {UPSTREAM_RELEASES_API}" + ) + return tag + + +def normalize_compute_cap(value: Any) -> str | None: + raw = str(value).strip() + if not raw: + return None + if "." in raw: + parts = raw.split(".", 1) + if len(parts) != 2: + return None + major, minor = parts + if not major.isdigit() or not minor.isdigit(): + return None + return f"{int(major)}{int(minor)}" + if raw.isdigit(): + return str(int(raw)) + return None + + +def normalize_compute_caps(compute_caps: Iterable[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for raw in compute_caps: + normalized_value = normalize_compute_cap(raw) + if normalized_value is None: + continue + if normalized_value in seen: + continue + seen.add(normalized_value) + normalized.append(normalized_value) + normalized.sort(key = int) + return normalized + + +def parse_cuda_visible_devices(value: str | None) -> list[str] | None: + if value is None: + return None + raw = value.strip() + if not raw or raw == "-1": + return [] + return [token.strip() for token in raw.split(",") if token.strip()] + + +def supports_explicit_visible_device_matching( + visible_devices: list[str] | None, +) -> bool: + if not visible_devices: + return False + for token in visible_devices: + lowered = token.lower() + if token.isdigit() or lowered.startswith("gpu-"): + continue + return False + return True + + +def select_visible_gpu_rows( + gpu_rows: Iterable[tuple[str, str, str]], + visible_devices: list[str] | None, +) -> list[tuple[str, str, str]]: + rows = list(gpu_rows) + if visible_devices is None: + return rows + if not visible_devices: + return [] + + by_index = {index: (index, uuid, cap) for index, uuid, cap in rows} + by_uuid = {uuid.lower(): (index, uuid, cap) for index, uuid, cap in rows} + selected: list[tuple[str, str, str]] = [] + seen_indices: set[str] = set() + for token in visible_devices: + row = by_index.get(token) + if row is None: + normalized_token = token.lower() + row = by_uuid.get(normalized_token) + if row is None and normalized_token.startswith("gpu-"): + row = by_uuid.get(normalized_token) + if row is None and not normalized_token.startswith("gpu-"): + row = by_uuid.get("gpu-" + normalized_token) + if row is None: + continue + index = row[0] + if index in seen_indices: + continue + seen_indices.add(index) + selected.append(row) + return selected + + +def dir_provides_exact_library(directory: str | Path, library: str) -> bool: + if not library: + return False + candidate = Path(directory) / library + return candidate.exists() and (candidate.is_file() or candidate.is_symlink()) + + +def linux_runtime_dirs_for_required_libraries( + required_libraries: Iterable[str], +) -> list[str]: + required = [library for library in required_libraries if library] + candidates: list[str | Path] = [] + + env_dirs = os.environ.get("CUDA_RUNTIME_LIB_DIR", "") + if env_dirs: + candidates.extend(part for part in env_dirs.split(os.pathsep) if part) + ld_library_path = os.environ.get("LD_LIBRARY_PATH", "") + if ld_library_path: + candidates.extend(part for part in ld_library_path.split(os.pathsep) if part) + + cuda_roots: list[Path] = [] + for name in ("CUDA_HOME", "CUDA_PATH", "CUDA_ROOT"): + value = os.environ.get(name) + if value: + cuda_roots.append(Path(value)) + cuda_roots.extend( + Path(path) for path in glob_paths("/usr/local/cuda", "/usr/local/cuda-*") + ) + + for root in cuda_roots: + candidates.extend( + [ + root / "lib", + root / "lib64", + root / "targets" / "x86_64-linux" / "lib", + ] + ) + + candidates.extend( + Path(path) + for path in glob_paths( + "/lib", + "/lib64", + "/usr/lib", + "/usr/lib64", + "/usr/local/lib", + "/usr/local/lib64", + "/lib/x86_64-linux-gnu", + "/usr/lib/x86_64-linux-gnu", + ) + ) + candidates.extend( + Path(path) + for path in glob_paths("/usr/local/lib/ollama/cuda_v*", "/usr/lib/wsl/lib") + ) + candidates.extend(Path(path) for path in python_runtime_dirs()) + candidates.extend(Path(path) for path in ldconfig_runtime_dirs(required)) + + resolved = dedupe_existing_dirs(candidates) + if not required: + return resolved + + matched: list[tuple[int, str]] = [] + for directory in resolved: + base = Path(directory) + provided = sum( + 1 for library in required if dir_provides_exact_library(directory, library) + ) + if provided: + matched.append((provided, directory)) + + matched.sort(key = lambda item: item[0], reverse = True) + return [directory for _, directory in matched] + + +def detected_linux_runtime_lines() -> tuple[list[str], dict[str, list[str]]]: + line_requirements = { + "cuda13": ["libcudart.so.13", "libcublas.so.13"], + "cuda12": ["libcudart.so.12", "libcublas.so.12"], + } + detected: list[str] = [] + runtime_dirs: dict[str, list[str]] = {} + for line, required in line_requirements.items(): + dirs = linux_runtime_dirs_for_required_libraries(required) + library_matches: dict[str, list[str]] = {} + matching_dirs: list[str] = [] + for library in required: + matched_dirs = [ + directory + for directory in dirs + if any(Path(directory).glob(f"{library}*")) + ] + if not matched_dirs: + library_matches = {} + matching_dirs = [] + break + library_matches[library] = matched_dirs + for directory in matched_dirs: + if directory not in matching_dirs: + matching_dirs.append(directory) + if library_matches: + detected.append(line) + runtime_dirs[line] = matching_dirs + return detected, runtime_dirs + + +def release_asset_map(release: dict[str, Any]) -> dict[str, str]: + assets = release.get("assets") + if not isinstance(assets, list): + return {} + return { + asset["name"]: asset.get("browser_download_url", "") + for asset in assets + if isinstance(asset, dict) + and isinstance(asset.get("name"), str) + and isinstance(asset.get("browser_download_url"), str) + } + + +def parse_published_artifact(raw: Any) -> PublishedLlamaArtifact | None: + if not isinstance(raw, dict): + raise ValueError("artifact entry was not an object") + asset_name = raw.get("asset_name") + install_kind = raw.get("install_kind") + if not isinstance(asset_name, str) or not asset_name: + raise ValueError("artifact.asset_name was missing or not a string") + if not isinstance(install_kind, str) or not install_kind: + raise ValueError( + f"artifact {asset_name} install_kind was missing or not a string" + ) + + supported_sms_raw = raw.get("supported_sms", []) + if not isinstance(supported_sms_raw, (list, tuple)): + raise ValueError(f"artifact {asset_name} supported_sms must be a list or tuple") + if any(not isinstance(value, (int, str)) for value in supported_sms_raw): + raise ValueError( + f"artifact {asset_name} supported_sms entries must be ints or strings" + ) + supported_sms = normalize_compute_caps(supported_sms_raw) + + min_sm_raw = raw.get("min_sm") + max_sm_raw = raw.get("max_sm") + try: + min_sm = int(min_sm_raw) if min_sm_raw is not None else None + max_sm = int(max_sm_raw) if max_sm_raw is not None else None + except (TypeError, ValueError) as exc: + raise ValueError( + f"artifact {asset_name} min_sm/max_sm were not integers" + ) from exc + runtime_line = raw.get("runtime_line") + coverage_class = raw.get("coverage_class") + bundle_profile = raw.get("bundle_profile") + rank_raw = raw.get("rank", 1000) + if runtime_line is not None and not isinstance(runtime_line, str): + raise ValueError(f"artifact {asset_name} runtime_line was not a string") + if coverage_class is not None and not isinstance(coverage_class, str): + raise ValueError(f"artifact {asset_name} coverage_class was not a string") + if bundle_profile is not None and not isinstance(bundle_profile, str): + raise ValueError(f"artifact {asset_name} bundle_profile was not a string") + try: + rank = int(rank_raw) + except (TypeError, ValueError): + raise ValueError(f"artifact {asset_name} rank was not an integer") + return PublishedLlamaArtifact( + asset_name = asset_name, + install_kind = install_kind, + runtime_line = runtime_line + if isinstance(runtime_line, str) and runtime_line + else None, + coverage_class = coverage_class + if isinstance(coverage_class, str) and coverage_class + else None, + supported_sms = supported_sms, + min_sm = min_sm, + max_sm = max_sm, + bundle_profile = bundle_profile + if isinstance(bundle_profile, str) and bundle_profile + else None, + rank = rank, + ) + + +def parse_published_release_bundle( + repo: str, release: dict[str, Any] +) -> PublishedReleaseBundle | None: + release_tag = release.get("tag_name") + if not isinstance(release_tag, str) or not release_tag: + return None + + assets = release_asset_map(release) + manifest_url = assets.get(DEFAULT_PUBLISHED_MANIFEST_ASSET) + if not manifest_url: + return None + + # Mixed repos are filtered by an explicit release-side manifest rather than + # by release tag or asset filename conventions. + manifest_payload = fetch_json(manifest_url) + if not isinstance(manifest_payload, dict): + raise RuntimeError( + f"published manifest {DEFAULT_PUBLISHED_MANIFEST_ASSET} was not a JSON object" + ) + component = manifest_payload.get("component") + upstream_tag = manifest_payload.get("upstream_tag") + if component != "llama.cpp": + return None + if not isinstance(upstream_tag, str) or not upstream_tag: + raise RuntimeError( + f"published manifest {DEFAULT_PUBLISHED_MANIFEST_ASSET} in {repo}@{release_tag} omitted upstream_tag" + ) + + artifacts_payload = manifest_payload.get("artifacts") + if not isinstance(artifacts_payload, list): + raise RuntimeError( + f"published manifest {DEFAULT_PUBLISHED_MANIFEST_ASSET} in {repo}@{release_tag} omitted artifacts" + ) + + artifacts: list[PublishedLlamaArtifact] = [] + for index, raw_artifact in enumerate(artifacts_payload): + try: + artifact = parse_published_artifact(raw_artifact) + except ValueError as exc: + log( + f"published artifact ignored for {repo}@{release_tag} artifact[{index}]: {exc}" + ) + continue + if artifact is not None: + artifacts.append(artifact) + selection_log = [ + f"published_release: repo={repo}", + f"published_release: tag={release_tag}", + f"published_release: manifest={DEFAULT_PUBLISHED_MANIFEST_ASSET}", + f"published_release: upstream_tag={upstream_tag}", + ] + return PublishedReleaseBundle( + repo = repo, + release_tag = release_tag, + upstream_tag = upstream_tag, + assets = assets, + manifest_asset_name = DEFAULT_PUBLISHED_MANIFEST_ASSET, + artifacts = artifacts, + selection_log = selection_log, + ) + + +def parse_approved_release_checksums( + repo: str, + release_tag: str, + payload: Any, +) -> ApprovedReleaseChecksums: + if not isinstance(payload, dict): + raise RuntimeError( + f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} was not a JSON object" + ) + if payload.get("component") != "llama.cpp": + raise RuntimeError( + f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} did not describe llama.cpp" + ) + payload_release_tag = payload.get("release_tag") + if not isinstance(payload_release_tag, str) or not payload_release_tag: + raise RuntimeError( + f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} omitted release_tag" + ) + if payload_release_tag != release_tag: + raise RuntimeError( + f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} release_tag={payload_release_tag} " + f"did not match pinned release tag {release_tag}" + ) + upstream_tag = payload.get("upstream_tag") + if not isinstance(upstream_tag, str) or not upstream_tag: + raise RuntimeError( + f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} omitted upstream_tag" + ) + artifacts_payload = payload.get("artifacts") + if not isinstance(artifacts_payload, dict): + raise RuntimeError( + f"published checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} omitted artifacts" + ) + + artifacts: dict[str, ApprovedArtifactHash] = {} + for asset_name, raw_entry in artifacts_payload.items(): + if not isinstance(asset_name, str) or not asset_name: + raise RuntimeError( + "published checksum asset used a non-string artifact key" + ) + if not isinstance(raw_entry, dict): + raise RuntimeError( + f"published checksum entry for {asset_name} was not an object" + ) + digest = normalize_sha256_digest(raw_entry.get("sha256")) + if not digest: + raise RuntimeError( + f"published checksum entry for {asset_name} omitted a valid sha256" + ) + repo_value = raw_entry.get("repo") + kind_value = raw_entry.get("kind") + artifacts[asset_name] = ApprovedArtifactHash( + asset_name = asset_name, + sha256 = digest, + repo = repo_value if isinstance(repo_value, str) and repo_value else None, + kind = kind_value if isinstance(kind_value, str) and kind_value else None, + ) + + source_commit = payload.get("source_commit") + return ApprovedReleaseChecksums( + repo = repo, + release_tag = release_tag, + upstream_tag = upstream_tag, + source_commit = source_commit + if isinstance(source_commit, str) and source_commit + else None, + artifacts = artifacts, + ) + + +def load_approved_release_checksums( + repo: str, release_tag: str +) -> ApprovedReleaseChecksums: + try: + release = github_release(repo, release_tag) + except Exception as exc: + raise PrebuiltFallback( + f"approved prebuilt release {repo}@{release_tag} was not available" + ) from exc + assets = release_asset_map(release) + checksum_url = assets.get(DEFAULT_PUBLISHED_SHA256_ASSET) + if not checksum_url: + raise PrebuiltFallback( + f"approved prebuilt release {repo}@{release_tag} did not expose {DEFAULT_PUBLISHED_SHA256_ASSET}" + ) + try: + payload = fetch_json(checksum_url) + checksums = parse_approved_release_checksums(repo, release_tag, payload) + except PrebuiltFallback: + raise + except Exception as exc: + raise PrebuiltFallback( + f"approved checksum asset {DEFAULT_PUBLISHED_SHA256_ASSET} in {repo}@{release_tag} was invalid" + ) from exc + return checksums + + +def iter_published_release_bundles( + repo: str, published_release_tag: str = "" +) -> Iterable[PublishedReleaseBundle]: + releases = ( + [github_release(repo, published_release_tag)] + if published_release_tag + else github_releases(repo) + ) + for release in releases: + if not published_release_tag and ( + release.get("draft") or release.get("prerelease") + ): + continue + try: + bundle = parse_published_release_bundle(repo, release) + except Exception as exc: + release_tag = release.get("tag_name", "unknown") + log(f"published release metadata ignored for {repo}@{release_tag}: {exc}") + continue + if bundle is None: + continue + yield bundle + + +def linux_cuda_choice_from_release( + host: HostInfo, + release: PublishedReleaseBundle, + preferred_runtime_line: str | None = None, + selection_preamble: Iterable[str] = (), +) -> LinuxCudaSelection | None: + host_sms = normalize_compute_caps(host.compute_caps) + detected_runtime_lines, runtime_dirs = detected_linux_runtime_lines() + driver_runtime_lines = compatible_linux_runtime_lines(host) + runtime_lines = [ + runtime_line + for runtime_line in detected_runtime_lines + if runtime_line in driver_runtime_lines + ] + ordered_runtime_lines = list(runtime_lines) + selection_log = ( + list(release.selection_log) + + list(selection_preamble) + + [ + f"linux_cuda_selection: release={release.release_tag}", + f"linux_cuda_selection: detected_sms={','.join(host_sms) if host_sms else 'unknown'}", + "linux_cuda_selection: detected_runtime_lines=" + + (",".join(detected_runtime_lines) if detected_runtime_lines else "none"), + "linux_cuda_selection: driver_runtime_lines=" + + (",".join(driver_runtime_lines) if driver_runtime_lines else "none"), + "linux_cuda_selection: compatible_runtime_lines=" + + (",".join(runtime_lines) if runtime_lines else "none"), + ] + ) + for runtime_line in ("cuda13", "cuda12"): + selection_log.append( + "linux_cuda_selection: runtime_dirs " + f"{runtime_line}=" + + ( + ",".join(runtime_dirs.get(runtime_line, [])) + if runtime_dirs.get(runtime_line) + else "none" + ) + ) + published_artifacts = [ + artifact + for artifact in release.artifacts + if artifact.install_kind == "linux-cuda" + ] + published_asset_names = sorted( + artifact.asset_name for artifact in published_artifacts + ) + selection_log.append( + "linux_cuda_selection: published_assets=" + + (",".join(published_asset_names) if published_asset_names else "none") + ) + + if not host_sms: + selection_log.append( + "linux_cuda_selection: compute capability detection unavailable; prefer portable by runtime line" + ) + if not runtime_lines: + selection_log.append( + "linux_cuda_selection: no Linux CUDA runtime line satisfied both runtime libraries and driver compatibility" + ) + return None + + if preferred_runtime_line: + if preferred_runtime_line in ordered_runtime_lines: + ordered_runtime_lines = [preferred_runtime_line] + [ + runtime_line + for runtime_line in ordered_runtime_lines + if runtime_line != preferred_runtime_line + ] + selection_log.append( + "linux_cuda_selection: torch_preferred_runtime_line=" + f"{preferred_runtime_line} reordered_attempts={','.join(ordered_runtime_lines)}" + ) + else: + selection_log.append( + "linux_cuda_selection: torch_preferred_runtime_line=" + f"{preferred_runtime_line} unavailable_on_host" + ) + + attempts: list[AssetChoice] = [] + seen_attempts: set[str] = set() + + def add_attempt( + artifact: PublishedLlamaArtifact, asset_url: str, reason: str + ) -> None: + asset_name = artifact.asset_name + if asset_name in seen_attempts: + return + seen_attempts.add(asset_name) + attempts.append( + AssetChoice( + repo = release.repo, + tag = release.release_tag, + name = asset_name, + url = asset_url, + source_label = "published", + is_ready_bundle = True, + install_kind = "linux-cuda", + bundle_profile = artifact.bundle_profile, + runtime_line = artifact.runtime_line, + coverage_class = artifact.coverage_class, + supported_sms = artifact.supported_sms, + min_sm = artifact.min_sm, + max_sm = artifact.max_sm, + selection_log = list(selection_log) + + [ + "linux_cuda_selection: selected " + f"{asset_name} runtime_line={artifact.runtime_line} coverage_class={artifact.coverage_class} reason={reason}" + ], + ) + ) + + for runtime_line in ordered_runtime_lines: + coverage_candidates: list[tuple[PublishedLlamaArtifact, str]] = [] + portable_candidate: tuple[PublishedLlamaArtifact, str] | None = None + for artifact in published_artifacts: + if artifact.runtime_line != runtime_line: + continue + asset_name = artifact.asset_name + asset_url = release.assets.get(asset_name) + if not asset_url: + selection_log.append( + f"linux_cuda_selection: reject {asset_name} missing asset" + ) + continue + if not host_sms and artifact.coverage_class != "portable": + selection_log.append( + "linux_cuda_selection: reject " + f"{asset_name} runtime_line={runtime_line} coverage_class={artifact.coverage_class} " + "reason=unknown_compute_caps_prefer_portable" + ) + continue + + if not artifact.supported_sms: + selection_log.append( + "linux_cuda_selection: reject " + f"{asset_name} runtime_line={runtime_line} coverage_class={artifact.coverage_class} " + "reason=artifact_missing_supported_sms" + ) + continue + if artifact.min_sm is None or artifact.max_sm is None: + selection_log.append( + "linux_cuda_selection: reject " + f"{asset_name} runtime_line={runtime_line} coverage_class={artifact.coverage_class} " + "reason=artifact_missing_sm_bounds" + ) + continue + + supported_sms = {str(value) for value in artifact.supported_sms} + missing_sms = [sm for sm in host_sms if sm not in supported_sms] + out_of_range_sms = [ + sm + for sm in host_sms + if not (artifact.min_sm <= int(sm) <= artifact.max_sm) + ] + reasons: list[str] = [] + if missing_sms: + reasons.append(f"missing_sms={','.join(missing_sms)}") + if out_of_range_sms: + reasons.append(f"out_of_range_sms={','.join(out_of_range_sms)}") + if reasons: + selection_log.append( + "linux_cuda_selection: reject " + f"{asset_name} runtime_line={runtime_line} coverage_class={artifact.coverage_class} " + f"coverage={artifact.min_sm}-{artifact.max_sm} supported={','.join(artifact.supported_sms)} " + f"reasons={' '.join(reasons)}" + ) + continue + + selection_log.append( + "linux_cuda_selection: accept " + f"{asset_name} runtime_line={runtime_line} coverage_class={artifact.coverage_class} " + f"coverage={artifact.min_sm}-{artifact.max_sm} supported={','.join(artifact.supported_sms)}" + ) + if artifact.coverage_class == "portable": + portable_candidate = (artifact, asset_url) + else: + coverage_candidates.append((artifact, asset_url)) + + if coverage_candidates: + artifact, url = sorted( + coverage_candidates, + key = lambda item: ( + (item[0].max_sm or 0) - (item[0].min_sm or 0), + item[0].rank, + item[0].max_sm or 0, + ), + )[0] + add_attempt(artifact, url, "best coverage for runtime line") + if portable_candidate: + artifact, url = portable_candidate + add_attempt(artifact, url, "portable fallback for runtime line") + + if not attempts: + return None + + selection_log.append( + "linux_cuda_selection: attempt_order=" + + ",".join(choice.name for choice in attempts) + ) + for attempt in attempts: + attempt.selection_log = list(selection_log) + [ + "linux_cuda_selection: attempt " + f"{attempt.name} runtime_line={attempt.runtime_line} coverage_class={attempt.coverage_class}" + ] + return LinuxCudaSelection(attempts = attempts, selection_log = selection_log) + + +def latest_published_linux_cuda_tag(host: HostInfo, published_repo: str) -> str | None: + for release in iter_published_release_bundles(published_repo): + if linux_cuda_choice_from_release(host, release): + return release.upstream_tag + return None + + +def iter_upstream_releases() -> Iterable[dict[str, Any]]: + for release in github_releases(UPSTREAM_REPO): + if release.get("draft") or release.get("prerelease"): + continue + yield release + + +def pinned_published_release_bundle( + repo: str, published_release_tag: str +) -> PublishedReleaseBundle: + bundle = next(iter_published_release_bundles(repo, published_release_tag), None) + if bundle is None: + raise PrebuiltFallback( + f"published release {repo}@{published_release_tag} did not expose a usable llama.cpp manifest" + ) + return bundle + + +def resolve_requested_llama_tag( + requested_tag: str | None, +) -> str: + if requested_tag and requested_tag != "latest": + return requested_tag + return latest_upstream_release_tag() + + +def resolve_requested_install_tag( + requested_tag: str | None, + published_release_tag: str = "", +) -> str: + approved_tag = APPROVED_PREBUILT_LLAMA_TAG + normalized_requested = requested_tag or "latest" + if normalized_requested not in {"latest", approved_tag}: + raise PrebuiltFallback( + f"prebuilt installs are pinned to approved release {approved_tag}; requested {normalized_requested}" + ) + if published_release_tag and published_release_tag != approved_tag: + raise PrebuiltFallback( + f"prebuilt installs require published release tag {approved_tag}; requested {published_release_tag}" + ) + return approved_tag + + +def run_capture( + command: list[str], + *, + timeout: int = 30, + check: bool = False, + env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + command, + capture_output = True, + text = True, + timeout = timeout, + env = env, + ) + if check and result.returncode != 0: + raise subprocess.CalledProcessError( + result.returncode, command, result.stdout, result.stderr + ) + return result + + +def detect_host() -> HostInfo: + system = platform.system() + machine = platform.machine().lower() + is_windows = system == "Windows" + is_linux = system == "Linux" + is_macos = system == "Darwin" + is_x86_64 = machine in {"x86_64", "amd64"} + is_arm64 = machine in {"arm64", "aarch64"} + + nvidia_smi = shutil.which("nvidia-smi") + driver_cuda_version = None + compute_caps: list[str] = [] + visible_cuda_devices = os.environ.get("CUDA_VISIBLE_DEVICES") + visible_device_tokens = parse_cuda_visible_devices(visible_cuda_devices) + has_physical_nvidia = False + has_usable_nvidia = False + if nvidia_smi: + try: + result = run_capture([nvidia_smi], timeout = 20) + merged = "\n".join(part for part in (result.stdout, result.stderr) if part) + if "NVIDIA-SMI" in merged: + has_physical_nvidia = True + has_usable_nvidia = visible_device_tokens != [] + for line in merged.splitlines(): + if "CUDA Version:" in line: + raw = line.split("CUDA Version:", 1)[1].strip().split()[0] + major, minor = raw.split(".", 1) + driver_cuda_version = (int(major), int(minor)) + break + except Exception: + pass + + try: + caps = run_capture( + [ + nvidia_smi, + "--query-gpu=index,uuid,compute_cap", + "--format=csv,noheader", + ], + timeout = 20, + ) + visible_gpu_rows: list[tuple[str, str, str]] = [] + for raw in caps.stdout.splitlines(): + parts = [part.strip() for part in raw.split(",")] + if len(parts) != 3: + continue + index, uuid, cap = parts + visible_gpu_row = select_visible_gpu_rows( + [(index, uuid, cap)], + visible_device_tokens, + ) + if not visible_gpu_row: + continue + visible_gpu_rows.extend(visible_gpu_row) + normalized_cap = normalize_compute_cap(cap) + if normalized_cap is None: + continue + if normalized_cap not in compute_caps: + compute_caps.append(normalized_cap) + + if visible_gpu_rows: + has_usable_nvidia = True + elif visible_device_tokens == []: + has_usable_nvidia = False + elif supports_explicit_visible_device_matching(visible_device_tokens): + has_usable_nvidia = False + elif has_physical_nvidia: + has_usable_nvidia = True + except Exception: + pass + + return HostInfo( + system = system, + machine = machine, + is_windows = is_windows, + is_linux = is_linux, + is_macos = is_macos, + is_x86_64 = is_x86_64, + is_arm64 = is_arm64, + nvidia_smi = nvidia_smi, + driver_cuda_version = driver_cuda_version, + compute_caps = compute_caps, + visible_cuda_devices = visible_cuda_devices, + has_physical_nvidia = has_physical_nvidia, + has_usable_nvidia = has_usable_nvidia, + ) + + +def pick_windows_cuda_runtime(host: HostInfo) -> str | None: + if not host.driver_cuda_version: + return None + major, minor = host.driver_cuda_version + if major > 13 or (major == 13 and minor >= 1): + return "13.1" + if major > 12 or (major == 12 and minor >= 4): + return "12.4" + return None + + +def compatible_linux_runtime_lines(host: HostInfo) -> list[str]: + if not host.driver_cuda_version: + return [] + major, _minor = host.driver_cuda_version + if major >= 13: + return ["cuda13", "cuda12"] + if major >= 12: + return ["cuda12"] + return [] + + +def windows_runtime_line_info() -> dict[str, tuple[str, ...]]: + return { + "cuda13": ("cudart64_13*.dll", "cublas64_13*.dll", "cublasLt64_13*.dll"), + "cuda12": ("cudart64_12*.dll", "cublas64_12*.dll", "cublasLt64_12*.dll"), + } + + +def detected_windows_runtime_lines() -> tuple[list[str], dict[str, list[str]]]: + dirs = windows_runtime_dirs() + detected: list[str] = [] + runtime_dirs: dict[str, list[str]] = {} + for runtime_line, required_patterns in windows_runtime_line_info().items(): + matching_dirs = windows_runtime_dirs_for_patterns(required_patterns, dirs) + if matching_dirs: + detected.append(runtime_line) + runtime_dirs[runtime_line] = matching_dirs + return detected, runtime_dirs + + +def compatible_windows_runtime_lines(host: HostInfo) -> list[str]: + driver_runtime = pick_windows_cuda_runtime(host) + if driver_runtime == "13.1": + return ["cuda13", "cuda12"] + if driver_runtime == "12.4": + return ["cuda12"] + return [] + + +def runtime_line_from_cuda_version(cuda_version: str | None) -> str | None: + if not cuda_version: + return None + raw = str(cuda_version).strip() + if not raw: + return None + major, _, _ = raw.partition(".") + if major == "12": + return "cuda12" + if major == "13": + return "cuda13" + return None + + +def detect_torch_cuda_runtime_preference(host: HostInfo) -> CudaRuntimePreference: + selection_log: list[str] = [] + if host.is_macos: + selection_log.append("torch_cuda_preference: skipped on macOS") + return CudaRuntimePreference(runtime_line = None, selection_log = selection_log) + if not (host.has_usable_nvidia and (host.is_linux or host.is_windows)): + selection_log.append( + "torch_cuda_preference: skipped because CUDA host prerequisites were not met" + ) + return CudaRuntimePreference(runtime_line = None, selection_log = selection_log) + + try: + import torch + except Exception as exc: + selection_log.append(f"torch_cuda_preference: import failed: {exc}") + return CudaRuntimePreference(runtime_line = None, selection_log = selection_log) + + cuda_version = getattr(getattr(torch, "version", None), "cuda", None) + if not isinstance(cuda_version, str) or not cuda_version.strip(): + selection_log.append( + "torch_cuda_preference: torch.version.cuda missing; skipping Torch shortcut" + ) + return CudaRuntimePreference(runtime_line = None, selection_log = selection_log) + + try: + cuda_available = bool(torch.cuda.is_available()) + except Exception as exc: + selection_log.append( + f"torch_cuda_preference: torch.cuda.is_available() failed: {exc}" + ) + return CudaRuntimePreference(runtime_line = None, selection_log = selection_log) + + if not cuda_available: + selection_log.append( + "torch_cuda_preference: torch.cuda.is_available() returned False; falling back to normal selection" + ) + return CudaRuntimePreference(runtime_line = None, selection_log = selection_log) + + runtime_line = runtime_line_from_cuda_version(cuda_version) + if runtime_line is None: + selection_log.append( + f"torch_cuda_preference: unsupported torch.version.cuda={cuda_version}; falling back to normal selection" + ) + return CudaRuntimePreference(runtime_line = None, selection_log = selection_log) + + selection_log.append( + "torch_cuda_preference: selected runtime_line=" + f"{runtime_line} from torch.version.cuda={cuda_version}" + ) + return CudaRuntimePreference(runtime_line = runtime_line, selection_log = selection_log) + + +def windows_cuda_attempts( + host: HostInfo, + llama_tag: str, + upstream_assets: dict[str, str], + preferred_runtime_line: str | None, + selection_preamble: Iterable[str] = (), +) -> list[AssetChoice]: + selection_log = list(selection_preamble) + runtime_by_line = {"cuda12": "12.4", "cuda13": "13.1"} + driver_runtime = pick_windows_cuda_runtime(host) + detected_runtime_lines, runtime_dirs = detected_windows_runtime_lines() + compatible_runtime_lines = compatible_windows_runtime_lines(host) + normal_runtime_lines: list[str] + if detected_runtime_lines: + normal_runtime_lines = [ + line for line in compatible_runtime_lines if line in detected_runtime_lines + ] + else: + normal_runtime_lines = compatible_runtime_lines + selection_log.append( + "windows_cuda_selection: driver_runtime=" + + (driver_runtime if driver_runtime else "unknown") + ) + selection_log.append( + "windows_cuda_selection: detected_runtime_lines=" + + (",".join(detected_runtime_lines) if detected_runtime_lines else "none") + ) + for runtime_line in ("cuda13", "cuda12"): + selection_log.append( + "windows_cuda_selection: runtime_dirs " + f"{runtime_line}=" + + ( + ",".join(runtime_dirs.get(runtime_line, [])) + if runtime_dirs.get(runtime_line) + else "none" + ) + ) + if detected_runtime_lines: + selection_log.append( + "windows_cuda_selection: host_runtime_order=" + + (",".join(normal_runtime_lines) if normal_runtime_lines else "none") + ) + else: + selection_log.append( + "windows_cuda_selection: no CUDA runtime DLL line detected; falling back to driver order" + ) + if not normal_runtime_lines: + if detected_runtime_lines: + selection_log.append( + "windows_cuda_selection: detected CUDA runtime DLLs were incompatible with the reported driver" + ) + fallback_runtime_lines = ( + ["cuda13", "cuda12"] + if driver_runtime == "13.1" + else (["cuda12"] if driver_runtime == "12.4" else []) + ) + normal_runtime_lines = fallback_runtime_lines + + runtime_order: list[str] = [] + if preferred_runtime_line and preferred_runtime_line in normal_runtime_lines: + runtime_order.append(preferred_runtime_line) + selection_log.append( + "windows_cuda_selection: torch_preferred_runtime_line=" + f"{preferred_runtime_line} reordered_attempts" + ) + elif preferred_runtime_line: + selection_log.append( + "windows_cuda_selection: torch_preferred_runtime_line=" + f"{preferred_runtime_line} unavailable_or_incompatible" + ) + else: + selection_log.append( + "windows_cuda_selection: no Torch runtime preference available" + ) + + runtime_order.extend( + runtime_line + for runtime_line in normal_runtime_lines + if runtime_line not in runtime_order + ) + selection_log.append( + "windows_cuda_selection: normal_runtime_order=" + + (",".join(normal_runtime_lines) if normal_runtime_lines else "none") + ) + selection_log.append( + "windows_cuda_selection: attempt_runtime_order=" + + (",".join(runtime_order) if runtime_order else "none") + ) + + attempts: list[AssetChoice] = [] + for runtime_line in runtime_order: + runtime = runtime_by_line[runtime_line] + upstream_name = f"llama-{llama_tag}-bin-win-cuda-{runtime}-x64.zip" + asset_url = upstream_assets.get(upstream_name) + if not asset_url: + selection_log.append( + f"windows_cuda_selection: skip missing asset {upstream_name}" + ) + continue + attempts.append( + AssetChoice( + repo = UPSTREAM_REPO, + tag = llama_tag, + name = upstream_name, + url = asset_url, + source_label = "upstream", + install_kind = "windows-cuda", + runtime_line = runtime_line, + selection_log = list(selection_log) + + [ + f"windows_cuda_selection: selected {upstream_name} runtime={runtime}" + ], + ) + ) + return attempts + + +def resolve_windows_cuda_choices( + host: HostInfo, llama_tag: str, upstream_assets: dict[str, str] +) -> list[AssetChoice]: + torch_preference = detect_torch_cuda_runtime_preference(host) + attempts = windows_cuda_attempts( + host, + llama_tag, + upstream_assets, + torch_preference.runtime_line, + torch_preference.selection_log, + ) + return attempts + + +def resolve_linux_cuda_choice( + host: HostInfo, llama_tag: str, published_repo: str, published_release_tag: str +) -> LinuxCudaSelection: + torch_preference = detect_torch_cuda_runtime_preference(host) + skipped_tag_mismatches = 0 + for release in iter_published_release_bundles( + published_repo, published_release_tag + ): + if release.upstream_tag != llama_tag: + skipped_tag_mismatches += 1 + continue + selection = linux_cuda_choice_from_release( + host, + release, + preferred_runtime_line = torch_preference.runtime_line, + selection_preamble = torch_preference.selection_log, + ) + if selection is not None: + return selection + if skipped_tag_mismatches: + log( + "published Linux CUDA selection skipped " + f"{skipped_tag_mismatches} release(s) with upstream_tag != {llama_tag}" + ) + raise PrebuiltFallback("no compatible published Linux CUDA bundle was found") + + +def resolve_upstream_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice: + upstream_assets = github_release_assets(UPSTREAM_REPO, llama_tag) + if host.is_linux and host.is_x86_64: + upstream_name = f"llama-{llama_tag}-bin-ubuntu-x64.tar.gz" + if upstream_name not in upstream_assets: + raise PrebuiltFallback("upstream Linux CPU asset was not found") + return AssetChoice( + repo = UPSTREAM_REPO, + tag = llama_tag, + name = upstream_name, + url = upstream_assets[upstream_name], + source_label = "upstream", + install_kind = "linux-cpu", + ) + + if host.is_windows and host.is_x86_64: + if host.has_usable_nvidia: + attempts = resolve_windows_cuda_choices(host, llama_tag, upstream_assets) + if attempts: + return attempts[0] + raise PrebuiltFallback("no compatible Windows CUDA asset was found") + + upstream_name = f"llama-{llama_tag}-bin-win-cpu-x64.zip" + if upstream_name not in upstream_assets: + raise PrebuiltFallback("upstream Windows CPU asset was not found") + return AssetChoice( + repo = UPSTREAM_REPO, + tag = llama_tag, + name = upstream_name, + url = upstream_assets[upstream_name], + source_label = "upstream", + install_kind = "windows-cpu", + ) + + if host.is_macos and host.is_arm64: + upstream_name = f"llama-{llama_tag}-bin-macos-arm64.tar.gz" + if upstream_name not in upstream_assets: + raise PrebuiltFallback("upstream macOS arm64 asset was not found") + return AssetChoice( + repo = UPSTREAM_REPO, + tag = llama_tag, + name = upstream_name, + url = upstream_assets[upstream_name], + source_label = "upstream", + install_kind = "macos-arm64", + ) + + if host.is_macos and host.is_x86_64: + upstream_name = f"llama-{llama_tag}-bin-macos-x64.tar.gz" + if upstream_name not in upstream_assets: + raise PrebuiltFallback("upstream macOS x64 asset was not found") + return AssetChoice( + repo = UPSTREAM_REPO, + tag = llama_tag, + name = upstream_name, + url = upstream_assets[upstream_name], + source_label = "upstream", + install_kind = "macos-x64", + ) + + raise PrebuiltFallback( + f"no prebuilt policy exists for {host.system} {host.machine}" + ) + + +def resolve_asset_choice( + host: HostInfo, llama_tag: str, published_repo: str, published_release_tag: str +) -> AssetChoice: + if host.is_linux and host.is_x86_64 and host.has_usable_nvidia: + return resolve_linux_cuda_choice( + host, llama_tag, published_repo, published_release_tag + ).primary + return resolve_upstream_asset_choice(host, llama_tag) + + +def extract_archive(archive_path: Path, destination: Path) -> None: + def safe_extract_path(base: Path, member_name: str) -> Path: + normalized = member_name.replace("\\", "/") + member_path = Path(normalized) + if member_path.is_absolute(): + raise PrebuiltFallback( + f"archive member used an absolute path: {member_name}" + ) + + target = (base / member_path).resolve() + base_resolved = base.resolve() + try: + target.relative_to(base_resolved) + except ValueError as exc: + raise PrebuiltFallback( + f"archive member escaped destination: {member_name}" + ) from exc + return target + + def safe_link_target( + base: Path, member_name: str, link_name: str, target: Path + ) -> tuple[str, Path]: + normalized = link_name.replace("\\", "/") + link_path = Path(normalized) + if link_path.is_absolute(): + raise PrebuiltFallback( + f"archive link used an absolute target: {member_name} -> {link_name}" + ) + if not normalized: + raise PrebuiltFallback(f"archive link used an empty target: {member_name}") + + resolved = (target.parent / link_path).resolve() + base_resolved = base.resolve() + try: + resolved.relative_to(base_resolved) + except ValueError as exc: + raise PrebuiltFallback( + f"archive link escaped destination: {member_name} -> {link_name}" + ) from exc + return normalized, resolved + + def extract_zip_safely(source: Path, base: Path) -> None: + with zipfile.ZipFile(source) as archive: + for member in archive.infolist(): + target = safe_extract_path(base, member.filename) + mode = (member.external_attr >> 16) & 0o170000 + if mode == 0o120000: + raise PrebuiltFallback( + f"zip archive contained a symlink entry: {member.filename}" + ) + if member.is_dir(): + target.mkdir(parents = True, exist_ok = True) + continue + target.parent.mkdir(parents = True, exist_ok = True) + with archive.open(member, "r") as src, target.open("wb") as dst: + shutil.copyfileobj(src, dst) + + def extract_tar_safely(source: Path, base: Path) -> None: + pending_links: list[tuple[tarfile.TarInfo, Path]] = [] + with tarfile.open(source, "r:gz") as archive: + for member in archive.getmembers(): + target = safe_extract_path(base, member.name) + if member.isdir(): + target.mkdir(parents = True, exist_ok = True) + continue + if member.islnk() or member.issym(): + pending_links.append((member, target)) + continue + if not member.isfile(): + raise PrebuiltFallback( + f"tar archive contained an unsupported entry: {member.name}" + ) + target.parent.mkdir(parents = True, exist_ok = True) + extracted = archive.extractfile(member) + if extracted is None: + raise PrebuiltFallback( + f"tar archive entry could not be read: {member.name}" + ) + with extracted, target.open("wb") as dst: + shutil.copyfileobj(extracted, dst) + + unresolved = list(pending_links) + while unresolved: + next_round: list[tuple[tarfile.TarInfo, Path]] = [] + progressed = False + for member, target in unresolved: + normalized_link, resolved_target = safe_link_target( + base, member.name, member.linkname, target + ) + if not resolved_target.exists() and not resolved_target.is_symlink(): + next_round.append((member, target)) + continue + if resolved_target.is_dir(): + raise PrebuiltFallback( + f"archive link targeted a directory: {member.name} -> {member.linkname}" + ) + + target.parent.mkdir(parents = True, exist_ok = True) + if target.exists() or target.is_symlink(): + target.unlink() + + if member.issym(): + target.symlink_to(normalized_link) + else: + shutil.copy2(resolved_target, target) + progressed = True + + if not progressed: + details = ", ".join( + f"{member.name} -> {member.linkname}" for member, _ in next_round + ) + raise PrebuiltFallback( + f"tar archive contained unresolved link entries: {details}" + ) + unresolved = next_round + + destination.mkdir(parents = True, exist_ok = True) + if archive_path.name.endswith(".zip"): + extract_zip_safely(archive_path, destination) + return + if archive_path.name.endswith(".tar.gz"): + extract_tar_safely(archive_path, destination) + return + raise PrebuiltFallback(f"unsupported archive format: {archive_path.name}") + + +def copy_globs( + source_dir: Path, destination: Path, patterns: list[str], *, required: bool = True +) -> None: + destination.mkdir(parents = True, exist_ok = True) + matched_sources: dict[str, Path] = {} + for path in sorted( + (candidate for candidate in source_dir.rglob("*") if candidate.is_file()), + key = lambda candidate: ( + len(candidate.relative_to(source_dir).parts), + str(candidate), + ), + ): + for pattern in patterns: + if fnmatch.fnmatch(path.name, pattern): + previous = matched_sources.get(path.name) + if previous is not None and previous != path: + raise PrebuiltFallback( + f"ambiguous archive layout for {path.name}: " + f"{previous.relative_to(source_dir)} and {path.relative_to(source_dir)}" + ) + matched_sources[path.name] = path + break + + if required and not matched_sources: + raise PrebuiltFallback(f"required files missing from {source_dir}: {patterns}") + + for name, path in matched_sources.items(): + shutil.copy2(path, destination / name) + + +def ensure_converter_scripts(install_dir: Path, llama_tag: str) -> None: + canonical = install_dir / "convert_hf_to_gguf.py" + if not canonical.exists(): + # Hydrated source tree should have placed this file already. + # Fall back to a network fetch so the install is not blocked. + raw_base = f"https://raw.githubusercontent.com/ggml-org/llama.cpp/{llama_tag}" + source_url = f"{raw_base}/convert_hf_to_gguf.py" + data = download_bytes( + source_url, + progress_label = f"Downloading {download_label_from_url(source_url)}", + ) + if not data: + raise RuntimeError(f"downloaded empty converter script from {source_url}") + if b"import " not in data and b"def " not in data and b"#!/" not in data: + raise RuntimeError( + f"downloaded converter script did not look like Python source: {source_url}" + ) + atomic_write_bytes(canonical, data) + legacy = install_dir / "convert-hf-to-gguf.py" + if legacy.exists() or legacy.is_symlink(): + legacy.unlink() + try: + legacy.symlink_to("convert_hf_to_gguf.py") + except OSError: + shutil.copy2(canonical, legacy) + + +def extracted_archive_root(extract_dir: Path) -> Path: + children = [path for path in extract_dir.iterdir()] + if len(children) == 1 and children[0].is_dir(): + return children[0] + return extract_dir + + +def copy_directory_contents(source_dir: Path, destination: Path) -> None: + destination.mkdir(parents = True, exist_ok = True) + for item in source_dir.iterdir(): + target = destination / item.name + if item.is_dir(): + shutil.copytree(item, target, dirs_exist_ok = True) + else: + shutil.copy2(item, target) + + +def hydrate_source_tree( + upstream_tag: str, + install_dir: Path, + work_dir: Path, + *, + expected_sha256: str, +) -> None: + archive_path = work_dir / f"llama.cpp-source-{upstream_tag}.tar.gz" + source_urls = upstream_source_archive_urls(upstream_tag) + extract_dir = Path(tempfile.mkdtemp(prefix = "source-extract-", dir = work_dir)) + + try: + log(f"downloading llama.cpp source tree for upstream tag {upstream_tag}") + last_exc: Exception | None = None + downloaded = False + for index, source_url in enumerate(source_urls): + try: + if index > 0: + log( + f"retrying source tree download from fallback URL: {source_url}" + ) + download_file_verified( + source_url, + archive_path, + expected_sha256 = expected_sha256, + label = f"llama.cpp source tree for {upstream_tag}", + ) + downloaded = True + break + except Exception as exc: + last_exc = exc + if index == len(source_urls) - 1: + raise + log(f"source tree download failed from {source_url}: {exc}") + if not downloaded: + assert last_exc is not None + raise last_exc + extract_archive(archive_path, extract_dir) + source_root = extracted_archive_root(extract_dir) + required_paths = [ + source_root / "CMakeLists.txt", + source_root / "convert_hf_to_gguf.py", + source_root / "gguf-py", + ] + missing = [ + str(path.relative_to(source_root)) + for path in required_paths + if not path.exists() + ] + if missing: + raise PrebuiltFallback( + "upstream source archive was missing required repo files: " + + ", ".join(missing) + ) + copy_directory_contents(source_root, install_dir) + except PrebuiltFallback: + raise + except Exception as exc: + raise PrebuiltFallback( + f"failed to hydrate upstream llama.cpp source tree for {upstream_tag}: {exc}" + ) from exc + finally: + remove_tree(extract_dir) + + +def normalize_install_layout(install_dir: Path, host: HostInfo) -> tuple[Path, Path]: + build_bin = install_dir / "build" / "bin" + if host.is_windows: + exec_dir = build_bin / "Release" + exec_dir.mkdir(parents = True, exist_ok = True) + return exec_dir / "llama-server.exe", exec_dir / "llama-quantize.exe" + + install_dir.mkdir(parents = True, exist_ok = True) + build_bin.mkdir(parents = True, exist_ok = True) + return install_dir / "llama-server", install_dir / "llama-quantize" + + +def discover_installed_executable(install_dir: Path, executable_name: str) -> Path: + direct = install_dir / executable_name + if direct.exists() and direct.is_file(): + return direct + candidate = next( + (path for path in install_dir.rglob(executable_name) if path.is_file()), None + ) + if candidate is None: + raise PrebuiltFallback(f"{executable_name} was not installed") + return candidate + + +def write_exec_wrapper(entrypoint: Path, target: Path) -> None: + relative_target = os.path.relpath(target, entrypoint.parent) + script = "\n".join( + [ + "#!/bin/sh", + f'exec "$(dirname "$0")/{relative_target}" "$@"', + "", + ] + ) + atomic_write_bytes(entrypoint, script.encode("utf-8")) + os.chmod(entrypoint, 0o755) + + +def create_exec_entrypoint(entrypoint: Path, target: Path) -> None: + if entrypoint == target: + return + if entrypoint.exists() or entrypoint.is_symlink(): + entrypoint.unlink() + try: + entrypoint.symlink_to(os.path.relpath(target, entrypoint.parent)) + except Exception: + write_exec_wrapper(entrypoint, target) + + +def overlay_directory_for_choice( + install_dir: Path, choice: AssetChoice, host: HostInfo +) -> Path: + if host.is_windows or choice.install_kind.startswith("windows"): + path = install_dir / "build" / "bin" / "Release" + else: + path = install_dir / "build" / "bin" + path.mkdir(parents = True, exist_ok = True) + return path + + +def runtime_patterns_for_choice(choice: AssetChoice) -> list[str]: + if choice.install_kind in {"linux-cpu", "linux-cuda"}: + return [ + "llama-server", + "llama-quantize", + "libllama.so*", + "libggml.so*", + "libggml-base.so*", + "libmtmd.so*", + "libggml-cpu-*.so*", + "libggml-cuda.so*", + "libggml-rpc.so*", + ] + if choice.install_kind in {"macos-arm64", "macos-x64"}: + return ["llama-server", "llama-quantize", "lib*.dylib"] + if choice.install_kind in {"windows-cpu", "windows-cuda"}: + return ["*.exe", "*.dll"] + raise PrebuiltFallback( + f"unsupported install kind for runtime overlay: {choice.install_kind}" + ) + + +def metadata_patterns_for_choice(choice: AssetChoice) -> list[str]: + patterns = ["BUILD_INFO.txt", "THIRD_PARTY_LICENSES.txt"] + if choice.install_kind.startswith("windows"): + patterns.append("LICENSE.txt") + else: + patterns.append("LICENSE") + return patterns + + +@contextmanager +def install_lock(lock_path: Path) -> Iterator[None]: + lock_path.parent.mkdir(parents = True, exist_ok = True) + + if FileLock is None: + # Fallback: exclusive file creation as a simple lock. + # Write our PID so stale locks from crashed processes can be detected. + fd: int | None = None + deadline = time.monotonic() + INSTALL_LOCK_TIMEOUT_SECONDS + while True: + try: + fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR) + os.write(fd, f"{os.getpid()}\n".encode()) + os.fsync(fd) + break + except FileExistsError: + # Check if the holder process is still alive + stale = False + try: + raw = lock_path.read_text().strip() + except FileNotFoundError: + # Lock vanished between our open attempt and read -- retry + continue + if not raw: + # File exists but PID not yet written -- another process + # just created it. Wait briefly for the write to land. + time.sleep(0.1) + continue + try: + holder_pid = int(raw) + os.kill(holder_pid, 0) # signal 0 = existence check + except ValueError: + # PID unreadable (corrupted file) + stale = True + except ProcessLookupError: + # Process is dead + stale = True + except PermissionError: + # Process is alive but owned by another user -- not stale + pass + if stale: + lock_path.unlink(missing_ok = True) + continue + if time.monotonic() >= deadline: + raise RuntimeError( + f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}" + ) + time.sleep(0.5) + try: + yield + finally: + if fd is not None: + os.close(fd) + lock_path.unlink(missing_ok = True) + return + + try: + with FileLock(lock_path, timeout = INSTALL_LOCK_TIMEOUT_SECONDS): + yield + except FileLockTimeout as exc: + raise RuntimeError( + f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for concurrent install lock: {lock_path}" + ) from exc + + +def install_lock_path(install_dir: Path) -> Path: + return install_dir.parent / f".{install_dir.name}.install.lock" + + +def install_staging_root(install_dir: Path) -> Path: + root = install_dir.parent / INSTALL_STAGING_ROOT_NAME + root.mkdir(parents = True, exist_ok = True) + return root + + +def prune_install_staging_root(install_dir: Path) -> None: + root = install_dir.parent / INSTALL_STAGING_ROOT_NAME + try: + root.rmdir() + except OSError: + pass + + +def create_install_staging_dir(install_dir: Path) -> Path: + staging_dir = Path( + tempfile.mkdtemp( + prefix = f"{install_dir.name}.staging-", dir = install_staging_root(install_dir) + ) + ) + log(f"created install staging dir {staging_dir}") + return staging_dir + + +def unique_install_side_path(install_dir: Path, label: str) -> Path: + root = install_staging_root(install_dir) + timestamp = time.strftime("%Y%m%d%H%M%S", time.gmtime()) + prefix = f"{install_dir.name}.{label}-{timestamp}-{os.getpid()}" + candidate = root / prefix + counter = 0 + while candidate.exists(): + counter += 1 + candidate = root / f"{prefix}-{counter}" + return candidate + + +def remove_tree(path: Path | None) -> None: + if path and path.exists(): + shutil.rmtree(path, ignore_errors = True) + + +def remove_tree_logged(path: Path | None, label: str) -> None: + if not path: + return + if not path.exists(): + log(f"{label} already absent at {path}") + return + log(f"removing {label} at {path}") + try: + shutil.rmtree(path) + except Exception as exc: + log(f"failed to remove {label} at {path}: {exc}") + raise + + +def cleanup_install_side_paths( + install_dir: Path, + *, + staging_dir: Path | None = None, + rollback_dir: Path | None = None, + failed_dir: Path | None = None, + active_dir: Path | None = None, +) -> None: + cleanup_failures: list[str] = [] + for label, path in ( + ("failed install path", failed_dir), + ("rollback path", rollback_dir), + ("active install path", active_dir), + ("staging dir", staging_dir), + ): + if not path: + continue + try: + remove_tree_logged(path, label) + except Exception as exc: + cleanup_failures.append(f"{label} ({path}): {exc}") + prune_install_staging_root(install_dir) + if cleanup_failures: + raise RuntimeError("cleanup failed for " + "; ".join(cleanup_failures)) + + +def confirm_install_tree(install_dir: Path, host: HostInfo) -> None: + if host.is_windows: + expected = [ + install_dir / "build" / "bin" / "Release" / "llama-server.exe", + install_dir / "build" / "bin" / "Release" / "llama-quantize.exe", + install_dir / "convert_hf_to_gguf.py", + install_dir / "gguf-py", + ] + else: + expected = [ + install_dir / "llama-server", + install_dir / "llama-quantize", + install_dir / "build" / "bin" / "llama-server", + install_dir / "build" / "bin" / "llama-quantize", + install_dir / "convert_hf_to_gguf.py", + install_dir / "gguf-py", + ] + + expected.append(install_dir / "UNSLOTH_PREBUILT_INFO.json") + missing = [str(path) for path in expected if not path.exists()] + if missing: + raise RuntimeError( + "activated install was missing expected files: " + ", ".join(missing) + ) + + +def activate_install_tree(staging_dir: Path, install_dir: Path, host: HostInfo) -> None: + rollback_dir: Path | None = None + failed_dir: Path | None = None + try: + if install_dir.exists(): + rollback_dir = unique_install_side_path(install_dir, "rollback") + log(f"moving existing install to rollback path {rollback_dir}") + os.replace(install_dir, rollback_dir) + log(f"moved existing install to rollback path {rollback_dir.name}") + + log(f"activating staged install {staging_dir} -> {install_dir}") + os.replace(staging_dir, install_dir) + log(f"activated staged install at {install_dir}") + log(f"confirming activated install tree at {install_dir}") + confirm_install_tree(install_dir, host) + log(f"activated install tree confirmed at {install_dir}") + except Exception as exc: + log(f"activation failed for staged install: {exc}") + try: + if install_dir.exists(): + failed_dir = unique_install_side_path(install_dir, "failed") + log(f"moving failed active install to {failed_dir}") + os.replace(install_dir, failed_dir) + elif staging_dir.exists(): + failed_dir = staging_dir + staging_dir = None + log(f"retaining failed staging tree at {failed_dir}") + + if rollback_dir and rollback_dir.exists(): + log(f"restoring rollback path {rollback_dir} -> {install_dir}") + os.replace(rollback_dir, install_dir) + log(f"restored previous install from rollback path {rollback_dir.name}") + raise PrebuiltFallback( + "staged prebuilt validation passed but activation failed; restored previous install " + f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + ) from exc + except PrebuiltFallback: + raise + except Exception as rollback_exc: + log(f"rollback after failed activation also failed: {rollback_exc}") + + log( + "rollback restoration failed; cleaning staging, install, and rollback paths before source build fallback" + ) + cleanup_error: Exception | None = None + try: + cleanup_install_side_paths( + install_dir, + staging_dir = staging_dir, + rollback_dir = rollback_dir, + failed_dir = failed_dir, + active_dir = install_dir, + ) + except Exception as cleanup_exc: + cleanup_error = cleanup_exc + log(f"cleanup after rollback failure also failed: {cleanup_exc}") + details = textwrap.shorten(str(exc), width = 200, placeholder = "...") + if cleanup_error is not None: + raise PrebuiltFallback( + "staged prebuilt validation passed but activation and rollback failed; " + f"cleanup also reported errors ({details}; cleanup={cleanup_error})" + ) from exc + raise PrebuiltFallback( + "staged prebuilt validation passed but activation and rollback failed; " + f"cleaned install state for fresh source build ({details})" + ) from exc + else: + if rollback_dir: + remove_tree_logged(rollback_dir, "rollback path") + finally: + remove_tree(failed_dir) + remove_tree(staging_dir) + prune_install_staging_root(install_dir) + + +def install_from_archives( + choice: AssetChoice, host: HostInfo, install_dir: Path, work_dir: Path +) -> tuple[Path, Path]: + main_archive = work_dir / choice.name + log(f"downloading {choice.name} from {choice.source_label} release") + if not choice.expected_sha256: + raise PrebuiltFallback( + f"approved checksum was missing for selected asset {choice.name}" + ) + download_file_verified( + choice.url, + main_archive, + expected_sha256 = choice.expected_sha256, + label = f"prebuilt archive {choice.name}", + ) + + install_dir.mkdir(parents = True, exist_ok = True) + extract_dir = Path(tempfile.mkdtemp(prefix = "extract-", dir = work_dir)) + + try: + extract_archive(main_archive, extract_dir) + source_dir = extract_dir + overlay_dir = overlay_directory_for_choice(install_dir, choice, host) + copy_globs( + source_dir, overlay_dir, runtime_patterns_for_choice(choice), required = True + ) + copy_globs( + source_dir, + install_dir, + metadata_patterns_for_choice(choice), + required = False, + ) + finally: + remove_tree(extract_dir) + + if host.is_windows: + exec_dir = install_dir / "build" / "bin" / "Release" + server_src = next(exec_dir.glob("llama-server.exe"), None) + quantize_src = next(exec_dir.glob("llama-quantize.exe"), None) + if server_src is None or quantize_src is None: + raise PrebuiltFallback("windows executables were not installed correctly") + return server_src, quantize_src + + build_bin = install_dir / "build" / "bin" + source_server = build_bin / "llama-server" + source_quantize = build_bin / "llama-quantize" + if not source_server.exists() or not source_quantize.exists(): + raise PrebuiltFallback( + "unix executables were not installed correctly into build/bin" + ) + os.chmod(source_server, 0o755) + os.chmod(source_quantize, 0o755) + + root_server = install_dir / "llama-server" + root_quantize = install_dir / "llama-quantize" + if source_server != root_server: + create_exec_entrypoint(root_server, source_server) + if source_quantize != root_quantize: + create_exec_entrypoint(root_quantize, source_quantize) + build_server = build_bin / "llama-server" + build_quantize = build_bin / "llama-quantize" + if source_server != build_server: + create_exec_entrypoint(build_server, source_server) + if source_quantize != build_quantize: + create_exec_entrypoint(build_quantize, source_quantize) + + return source_server, source_quantize + + +def ensure_repo_shape(install_dir: Path) -> None: + required = [ + install_dir / "CMakeLists.txt", + install_dir / "convert_hf_to_gguf.py", + install_dir / "gguf-py", + ] + missing = [ + str(path.relative_to(install_dir)) for path in required if not path.exists() + ] + if missing: + raise PrebuiltFallback( + "hydrated llama.cpp source tree was missing: " + ", ".join(missing) + ) + + +def validation_model_cache_path(install_dir: Path) -> Path: + cache_dir = install_dir.parent / VALIDATION_MODEL_CACHE_DIRNAME + cache_dir.mkdir(parents = True, exist_ok = True) + return cache_dir / VALIDATION_MODEL_CACHE_FILENAME + + +def validated_validation_model_bytes(data: bytes) -> bytes: + if not data: + raise RuntimeError(f"downloaded empty validation model from {TEST_MODEL_URL}") + digest = hashlib.sha256(data).hexdigest() + if digest != TEST_MODEL_SHA256: + raise RuntimeError( + "validation model checksum mismatch: " + f"expected={TEST_MODEL_SHA256} actual={digest}" + ) + return data + + +def download_validation_model(path: Path, cache_path: Path | None = None) -> None: + try: + data: bytes | None = None + if cache_path and cache_path.exists(): + try: + data = validated_validation_model_bytes(cache_path.read_bytes()) + log(f"using cached tiny GGUF validation model from {cache_path}") + except Exception as exc: + log( + f"cached tiny GGUF validation model was invalid; refreshing cache ({exc})" + ) + data = None + if data is None: + log("downloading tiny GGUF validation model") + data = validated_validation_model_bytes( + download_bytes( + TEST_MODEL_URL, + progress_label = f"Downloading {download_label_from_url(TEST_MODEL_URL)}", + ) + ) + if cache_path is not None: + atomic_write_bytes(cache_path, data) + atomic_write_bytes(path, data) + except Exception as exc: + raise PrebuiltFallback(f"validation model unavailable: {exc}") from exc + + +def free_local_port() -> int: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + _, port = sock.getsockname() + sock.close() + return int(port) + + +def read_log_excerpt(log_path: Path, *, max_lines: int = 60) -> str: + try: + content = log_path.read_text(encoding = "utf-8", errors = "replace") + except FileNotFoundError: + return "" + return "\n".join(content.splitlines()[-max_lines:]) + + +def is_retryable_server_bind_error( + exc: Exception | None, + output: str = "", + *, + exited_quickly: bool = False, +) -> bool: + haystack = output.lower() + bind_markers = ( + "address already in use", + "only one usage of each socket address", + "failed to bind", + "bind failed", + "failed to listen", + "errno 98", + "errno 10048", + ) + if any(marker in haystack for marker in bind_markers): + return True + + if isinstance(exc, urllib.error.URLError): + reason = exc.reason + if exited_quickly and isinstance(reason, ConnectionRefusedError): + return True + if isinstance(reason, OSError) and reason.errno in { + 98, + 99, + 111, + 10048, + 10049, + 10061, + }: + return exited_quickly + if exited_quickly and isinstance(exc, ConnectionRefusedError): + return True + if isinstance(exc, OSError) and exc.errno in {98, 99, 111, 10048, 10049, 10061}: + return exited_quickly + return False + + +def dedupe_existing_dirs(paths: Iterable[str | Path]) -> list[str]: + unique: list[str] = [] + seen: set[str] = set() + for raw in paths: + if not raw: + continue + path = Path(raw).expanduser() + if not path.is_dir(): + continue + resolved = str(path.resolve()) + if resolved in seen: + continue + seen.add(resolved) + unique.append(resolved) + return unique + + +def linux_missing_libraries( + binary_path: Path, *, env: dict[str, str] | None = None +) -> list[str]: + try: + result = run_capture(["ldd", str(binary_path)], timeout = 20, env = env) + except Exception: + return [] + + missing: list[str] = [] + for line in (result.stdout + result.stderr).splitlines(): + line = line.strip() + if "=> not found" not in line: + continue + library = line.split("=>", 1)[0].strip() + if library and library not in missing: + missing.append(library) + return missing + + +def python_runtime_dirs() -> list[str]: + candidates: list[Path] = [] + search_roots = [Path(entry) for entry in sys.path if entry] + try: + search_roots.extend(Path(path) for path in site.getsitepackages()) + except Exception: + pass + try: + user_site = site.getusersitepackages() + if user_site: + search_roots.append(Path(user_site)) + except Exception: + pass + + for root in search_roots: + if not root.is_dir(): + continue + candidates.extend(root.glob("nvidia/*/lib")) + candidates.extend(root.glob("nvidia/*/bin")) + candidates.extend(root.glob("torch/lib")) + return dedupe_existing_dirs(candidates) + + +def ldconfig_runtime_dirs(required_libraries: Iterable[str]) -> list[str]: + try: + result = run_capture(["ldconfig", "-p"], timeout = 20) + except Exception: + return [] + + required = set(required_libraries) + candidates: list[str] = [] + for line in result.stdout.splitlines(): + if "=>" not in line: + continue + library, _, location = line.partition("=>") + library = library.strip().split()[0] + if required and library not in required: + continue + path = Path(location.strip()).parent + candidates.append(str(path)) + return dedupe_existing_dirs(candidates) + + +def linux_runtime_dirs(binary_path: Path) -> list[str]: + missing = linux_missing_libraries(binary_path) + if not missing: + return [] + return linux_runtime_dirs_for_required_libraries(missing) + + +def preflight_linux_installed_binaries( + binaries: Iterable[Path], + install_dir: Path, + host: HostInfo, +) -> None: + if not host.is_linux: + return + + issues: list[str] = [] + for binary_path in binaries: + env = binary_env(binary_path, install_dir, host) + missing = linux_missing_libraries(binary_path, env = env) + if not missing: + continue + runtime_dirs = [ + part for part in env.get("LD_LIBRARY_PATH", "").split(os.pathsep) if part + ] + issues.append( + f"{binary_path.name}: missing={','.join(missing)} " + f"ld_library_path={','.join(runtime_dirs) if runtime_dirs else 'none'}" + ) + + if issues: + raise PrebuiltFallback( + "linux extracted binary preflight failed:\n" + "\n".join(issues) + ) + + +def glob_paths(*patterns: str) -> list[str]: + matches: list[str] = [] + for pattern in patterns: + if any(char in pattern for char in "*?[]"): + matches.extend(str(path) for path in Path("/").glob(pattern.lstrip("/"))) + else: + matches.append(pattern) + return matches + + +def windows_runtime_dirs() -> list[str]: + candidates: list[str | Path] = [] + + env_dirs = os.environ.get("CUDA_RUNTIME_DLL_DIR", "") + if env_dirs: + candidates.extend(part for part in env_dirs.split(os.pathsep) if part) + + path_dirs = os.environ.get("PATH", "") + if path_dirs: + candidates.extend(part for part in path_dirs.split(os.pathsep) if part) + + cuda_roots: list[Path] = [] + for name in ("CUDA_PATH", "CUDA_HOME", "CUDA_ROOT"): + value = os.environ.get(name) + if value: + cuda_roots.append(Path(value)) + + for root in cuda_roots: + candidates.extend([root / "bin", root / "lib" / "x64"]) + + program_files = os.environ.get("ProgramFiles", r"C:\Program Files") + toolkit_base = Path(program_files) / "NVIDIA GPU Computing Toolkit" / "CUDA" + if toolkit_base.is_dir(): + candidates.extend(toolkit_base.glob("v*/bin")) + candidates.extend(toolkit_base.glob("v*/lib/x64")) + + candidates.extend(Path(path) for path in python_runtime_dirs()) + return dedupe_existing_dirs(candidates) + + +def windows_runtime_dirs_for_patterns( + required_patterns: Iterable[str], + candidate_dirs: Iterable[str] | None = None, +) -> list[str]: + directories = ( + list(candidate_dirs) if candidate_dirs is not None else windows_runtime_dirs() + ) + matching_dirs: list[str] = [] + for pattern in required_patterns: + matched_dirs = [ + directory for directory in directories if any(Path(directory).glob(pattern)) + ] + if not matched_dirs: + return [] + for directory in matched_dirs: + if directory not in matching_dirs: + matching_dirs.append(directory) + return matching_dirs + + +def windows_runtime_dirs_for_runtime_line(runtime_line: str | None) -> list[str]: + if not runtime_line: + return [] + patterns = windows_runtime_line_info().get(runtime_line) + if not patterns: + return [] + return windows_runtime_dirs_for_patterns(patterns) + + +def binary_env( + binary_path: Path, + install_dir: Path, + host: HostInfo, + *, + runtime_line: str | None = None, +) -> dict[str, str]: + env = os.environ.copy() + if host.is_windows: + path_dirs = [ + str(binary_path.parent), + *windows_runtime_dirs_for_runtime_line(runtime_line), + ] + existing = [part for part in env.get("PATH", "").split(os.pathsep) if part] + env["PATH"] = os.pathsep.join(dedupe_existing_dirs([*path_dirs, *existing])) + elif host.is_linux: + ld_dirs = [ + str(binary_path.parent), + str(install_dir), + *linux_runtime_dirs(binary_path), + ] + existing = [ + part for part in env.get("LD_LIBRARY_PATH", "").split(os.pathsep) if part + ] + env["LD_LIBRARY_PATH"] = os.pathsep.join( + dedupe_existing_dirs([*ld_dirs, *existing]) + ) + elif host.is_macos: + dyld_dirs = [str(binary_path.parent), str(install_dir)] + existing = [ + part for part in env.get("DYLD_LIBRARY_PATH", "").split(os.pathsep) if part + ] + env["DYLD_LIBRARY_PATH"] = os.pathsep.join( + dedupe_existing_dirs([*dyld_dirs, *existing]) + ) + return env + + +def validate_quantize( + quantize_path: Path, + probe_path: Path, + quantized_path: Path, + install_dir: Path, + host: HostInfo, + *, + runtime_line: str | None = None, +) -> None: + command = [str(quantize_path), str(probe_path), str(quantized_path), "Q6_K", "2"] + result = subprocess.run( + command, + capture_output = True, + text = True, + timeout = 120, + env = binary_env(quantize_path, install_dir, host, runtime_line = runtime_line), + ) + if ( + result.returncode != 0 + or not quantized_path.exists() + or quantized_path.stat().st_size == 0 + ): + raise PrebuiltFallback( + "llama-quantize validation failed:\n" + + result.stdout + + ("\n" + result.stderr if result.stderr else "") + ) + + +def validate_server( + server_path: Path, + probe_path: Path, + host: HostInfo, + install_dir: Path, + *, + runtime_line: str | None = None, +) -> None: + last_failure: PrebuiltFallback | None = None + for port_attempt in range(1, SERVER_PORT_BIND_ATTEMPTS + 1): + port = free_local_port() + command = [ + str(server_path), + "-m", + str(probe_path), + "--host", + "127.0.0.1", + "--port", + str(port), + "-c", + "32", + "--parallel", + "1", + "--threads", + "1", + "--ubatch-size", + "32", + "--batch-size", + "32", + ] + if host.has_usable_nvidia or (host.is_macos and host.is_arm64): + command.extend(["--n-gpu-layers", "1"]) + + log_fd, log_name = tempfile.mkstemp(prefix = "llama-server-", suffix = ".log") + os.close(log_fd) + log_path = Path(log_name) + process: subprocess.Popen[str] | None = None + try: + with log_path.open("w", encoding = "utf-8", errors = "replace") as log_handle: + process = subprocess.Popen( + command, + stdout = log_handle, + stderr = subprocess.STDOUT, + text = True, + env = binary_env( + server_path, install_dir, host, runtime_line = runtime_line + ), + ) + deadline = time.time() + 20 + startup_started = time.time() + response_body = "" + last_error: Exception | None = None + while time.time() < deadline: + if process.poll() is not None: + process.wait(timeout = 5) + log_handle.flush() + output = read_log_excerpt(log_path) + exited_quickly = ( + time.time() - startup_started + ) <= SERVER_BIND_RETRY_WINDOW_SECONDS + failure = PrebuiltFallback( + "llama-server exited during startup:\n" + output + ) + if ( + port_attempt < SERVER_PORT_BIND_ATTEMPTS + and is_retryable_server_bind_error( + last_error, + output, + exited_quickly = exited_quickly, + ) + ): + log( + f"llama-server startup hit a port race on {port}; retrying with a fresh port " + f"({port_attempt}/{SERVER_PORT_BIND_ATTEMPTS})" + ) + last_failure = failure + break + raise failure + + payload = json.dumps({"prompt": "a", "n_predict": 1}).encode( + "utf-8" + ) + request = urllib.request.Request( + f"http://127.0.0.1:{port}/completion", + data = payload, + headers = {"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout = 5) as response: + status_code = response.status + response_body = response.read().decode("utf-8", "replace") + if status_code == 200: + return + last_error = RuntimeError( + f"unexpected HTTP status {status_code}" + ) + except urllib.error.HTTPError as exc: + response_body = exc.read().decode("utf-8", "replace") + last_error = exc + except Exception as exc: + last_error = exc + time.sleep(0.5) + else: + log_handle.flush() + output = read_log_excerpt(log_path) + raise PrebuiltFallback( + "llama-server completion validation timed out" + + (f" ({last_error})" if last_error else "") + + ":\n" + + output + + ("\n" + response_body if response_body else "") + ) + finally: + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout = 5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout = 5) + try: + log_path.unlink(missing_ok = True) + except Exception: + pass + if last_failure is not None: + raise last_failure + raise PrebuiltFallback("llama-server validation failed unexpectedly") + + +def collect_system_report( + host: HostInfo, choice: AssetChoice | None, install_dir: Path +) -> str: + lines = [ + f"platform={host.system} machine={host.machine}", + f"driver_cuda_version={host.driver_cuda_version}", + f"compute_caps={','.join(host.compute_caps) if host.compute_caps else 'unknown'}", + f"cuda_visible_devices={host.visible_cuda_devices if host.visible_cuda_devices is not None else 'unset'}", + f"has_physical_nvidia={host.has_physical_nvidia}", + f"has_usable_nvidia={host.has_usable_nvidia}", + f"chosen_asset={(choice.name if choice else 'none')}", + f"asset_source={(choice.source_label if choice else 'none')}", + ] + if host.is_linux and host.has_physical_nvidia: + runtime_lines, runtime_dirs = detected_linux_runtime_lines() + lines.append( + "linux_runtime_lines=" + + (",".join(runtime_lines) if runtime_lines else "none") + ) + for runtime_line in ("cuda13", "cuda12"): + lines.append( + f"linux_runtime_dirs_{runtime_line}=" + + ( + ",".join(runtime_dirs.get(runtime_line, [])) + if runtime_dirs.get(runtime_line) + else "none" + ) + ) + if choice and choice.selection_log: + lines.append("selection_log:") + lines.extend(choice.selection_log) + if host.nvidia_smi: + try: + smi = run_capture([host.nvidia_smi], timeout = 20) + excerpt = "\n".join((smi.stdout + smi.stderr).splitlines()[:20]) + lines.append("nvidia-smi:") + lines.append(excerpt) + except Exception as exc: + lines.append(f"nvidia-smi error: {exc}") + + if host.is_linux: + server_binary = install_dir / "llama-server" + if server_binary.exists(): + server_env = binary_env(server_binary, install_dir, host) + lines.append( + "linux_missing_libs=" + + ( + ",".join(linux_missing_libraries(server_binary, env = server_env)) + or "none" + ) + ) + lines.append( + "linux_runtime_dirs=" + + ( + ",".join( + [ + part + for part in server_env.get("LD_LIBRARY_PATH", "").split( + os.pathsep + ) + if part + ] + ) + or "none" + ) + ) + try: + ldd = run_capture( + ["ldd", str(server_binary)], timeout = 20, env = server_env + ) + lines.append("ldd llama-server:") + lines.append((ldd.stdout + ldd.stderr).strip()) + except Exception as exc: + lines.append(f"ldd error: {exc}") + elif host.is_windows: + lines.append( + "windows_runtime_dirs=" + (",".join(windows_runtime_dirs()) or "none") + ) + runtime_lines, runtime_dirs = detected_windows_runtime_lines() + lines.append( + "windows_runtime_lines=" + + (",".join(runtime_lines) if runtime_lines else "none") + ) + for runtime_line in ("cuda13", "cuda12"): + lines.append( + f"windows_runtime_dirs_{runtime_line}=" + + ( + ",".join(runtime_dirs.get(runtime_line, [])) + if runtime_dirs.get(runtime_line) + else "none" + ) + ) + elif host.is_macos: + server_binary = install_dir / "llama-server" + if server_binary.exists(): + try: + otool = run_capture(["otool", "-L", str(server_binary)], timeout = 20) + lines.append("otool -L llama-server:") + lines.append((otool.stdout + otool.stderr).strip()) + except Exception as exc: + lines.append(f"otool error: {exc}") + + return "\n".join(lines) + + +def apply_approved_hashes( + attempts: Iterable[AssetChoice], + checksums: ApprovedReleaseChecksums, +) -> list[AssetChoice]: + approved_attempts: list[AssetChoice] = [] + missing_assets: list[str] = [] + for attempt in attempts: + approved = checksums.artifacts.get(attempt.name) + if approved is None: + missing_assets.append(attempt.name) + continue + attempt.expected_sha256 = approved.sha256 + approved_attempts.append(attempt) + if not approved_attempts: + missing_text = ", ".join(missing_assets) if missing_assets else "none" + raise PrebuiltFallback( + "approved checksum asset did not contain the selected prebuilt archive(s): " + f"{missing_text}" + ) + return approved_attempts + + +def require_approved_source_hash( + checksums: ApprovedReleaseChecksums, llama_tag: str +) -> ApprovedArtifactHash: + source_asset_name = source_archive_logical_name(llama_tag) + approved_source = checksums.artifacts.get(source_asset_name) + if approved_source is None: + raise PrebuiltFallback( + f"approved checksum asset did not contain source archive {source_asset_name}" + ) + return approved_source + + +def resolve_install_attempts( + llama_tag: str, + host: HostInfo, + published_repo: str, + published_release_tag: str, +) -> tuple[str, str, list[AssetChoice], ApprovedReleaseChecksums]: + requested_tag = llama_tag + resolved_tag = resolve_requested_install_tag(llama_tag, published_release_tag) + checksums = load_approved_release_checksums(published_repo, resolved_tag) + require_approved_source_hash(checksums, resolved_tag) + + if host.is_linux and host.is_x86_64 and host.has_usable_nvidia: + linux_cuda_selection = resolve_linux_cuda_choice( + host, resolved_tag, published_repo, published_release_tag + ) + attempts = apply_approved_hashes(linux_cuda_selection.attempts, checksums) + if not attempts: + raise PrebuiltFallback("no compatible Linux CUDA asset was found") + log_lines(linux_cuda_selection.selection_log) + return requested_tag, resolved_tag, attempts, checksums + + if host.is_windows and host.is_x86_64 and host.has_usable_nvidia: + upstream_assets = github_release_assets(UPSTREAM_REPO, resolved_tag) + attempts = apply_approved_hashes( + resolve_windows_cuda_choices(host, resolved_tag, upstream_assets), checksums + ) + if not attempts: + raise PrebuiltFallback("no compatible Windows CUDA asset was found") + if attempts[0].selection_log: + log_lines(attempts[0].selection_log) + return requested_tag, resolved_tag, attempts, checksums + + choice = resolve_asset_choice( + host, resolved_tag, published_repo, published_release_tag + ) + approved_attempts = apply_approved_hashes([choice], checksums) + if choice.selection_log: + log_lines(choice.selection_log) + return requested_tag, resolved_tag, approved_attempts, checksums + + +def write_prebuilt_metadata( + install_dir: Path, + *, + requested_tag: str, + llama_tag: str, + choice: AssetChoice, + prebuilt_fallback_used: bool, +) -> None: + metadata = { + "requested_tag": requested_tag, + "tag": llama_tag, + "asset": choice.name, + "source": choice.source_label, + "bundle_profile": choice.bundle_profile, + "runtime_line": choice.runtime_line, + "coverage_class": choice.coverage_class, + "prebuilt_fallback_used": prebuilt_fallback_used, + "installed_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + (install_dir / "UNSLOTH_PREBUILT_INFO.json").write_text( + json.dumps(metadata, indent = 2) + "\n" + ) + + +def validate_prebuilt_choice( + choice: AssetChoice, + host: HostInfo, + install_dir: Path, + work_dir: Path, + probe_path: Path, + *, + requested_tag: str, + llama_tag: str, + approved_checksums: ApprovedReleaseChecksums, + prebuilt_fallback_used: bool, + quantized_path: Path, +) -> tuple[Path, Path]: + source_archive = approved_checksums.artifacts.get( + source_archive_logical_name(llama_tag) + ) + if source_archive is None: + raise PrebuiltFallback( + f"approved checksum asset did not contain source archive {source_archive_logical_name(llama_tag)}" + ) + log(f"hydrating upstream llama.cpp source for {llama_tag} into {install_dir}") + hydrate_source_tree( + llama_tag, + install_dir, + work_dir, + expected_sha256 = source_archive.sha256, + ) + log(f"overlaying prebuilt bundle {choice.name} into {install_dir}") + server_path, quantize_path = install_from_archives( + choice, host, install_dir, work_dir + ) + preflight_linux_installed_binaries((server_path, quantize_path), install_dir, host) + ensure_repo_shape(install_dir) + write_prebuilt_metadata( + install_dir, + requested_tag = requested_tag, + llama_tag = llama_tag, + choice = choice, + prebuilt_fallback_used = prebuilt_fallback_used, + ) + validate_quantize( + quantize_path, + probe_path, + quantized_path, + install_dir, + host, + runtime_line = choice.runtime_line, + ) + validate_server( + server_path, + probe_path, + host, + install_dir, + runtime_line = choice.runtime_line, + ) + log(f"staged prebuilt validation succeeded for {choice.name}") + return server_path, quantize_path + + +def validate_prebuilt_attempts( + attempts: Iterable[AssetChoice], + host: HostInfo, + install_dir: Path, + work_dir: Path, + probe_path: Path, + *, + requested_tag: str, + llama_tag: str, + approved_checksums: ApprovedReleaseChecksums, +) -> tuple[AssetChoice, Path, bool]: + attempt_list = list(attempts) + if not attempt_list: + raise PrebuiltFallback("no prebuilt bundle attempts were available") + + tried_fallback = False + for index, attempt in enumerate(attempt_list): + if index > 0: + tried_fallback = True + log( + "retrying CUDA prebuilt " + f"{attempt.name} install_kind={attempt.install_kind} " + f"runtime_line={attempt.runtime_line} coverage_class={attempt.coverage_class}" + ) + + staging_dir = create_install_staging_dir(install_dir) + quantized_path = work_dir / f"stories260K-q4-{index}.gguf" + if quantized_path.exists(): + quantized_path.unlink() + try: + validate_prebuilt_choice( + attempt, + host, + staging_dir, + work_dir, + probe_path, + requested_tag = requested_tag, + llama_tag = llama_tag, + approved_checksums = approved_checksums, + prebuilt_fallback_used = tried_fallback, + quantized_path = quantized_path, + ) + except Exception as exc: + remove_tree(staging_dir) + prune_install_staging_root(install_dir) + if isinstance(exc, PrebuiltFallback): + attempt_error = exc + else: + attempt_error = PrebuiltFallback( + f"candidate attempt failed before activation for {attempt.name}: {exc}" + ) + if index == len(attempt_list) - 1: + raise attempt_error from exc + log( + "selected CUDA bundle failed before activation; trying next prebuilt fallback " + f"({textwrap.shorten(str(attempt_error), width = 200, placeholder = '...')})" + ) + continue + + return attempt, staging_dir, tried_fallback + + raise PrebuiltFallback("no prebuilt bundle passed validation") + + +def install_prebuilt( + install_dir: Path, llama_tag: str, published_repo: str, published_release_tag: str +) -> None: + host = detect_host() + choice: AssetChoice | None = None + try: + with install_lock(install_lock_path(install_dir)): + if install_dir.exists(): + log( + f"existing llama.cpp install detected at {install_dir}; validating staged prebuilt update before replacement" + ) + else: + log( + f"no existing llama.cpp install detected at {install_dir}; performing fresh prebuilt install" + ) + requested_tag, llama_tag, attempts, approved_checksums = ( + resolve_install_attempts( + llama_tag, + host, + published_repo, + published_release_tag, + ) + ) + choice = attempts[0] + log( + f"selected {choice.name} ({choice.source_label}) for {host.system} {host.machine}" + ) + with tempfile.TemporaryDirectory(prefix = "unsloth-llama-prebuilt-") as tmp: + work_dir = Path(tmp) + probe_path = work_dir / "stories260K.gguf" + download_validation_model( + probe_path, validation_model_cache_path(install_dir) + ) + choice, selected_staging_dir, _ = validate_prebuilt_attempts( + attempts, + host, + install_dir, + work_dir, + probe_path, + requested_tag = requested_tag, + llama_tag = llama_tag, + approved_checksums = approved_checksums, + ) + activate_install_tree(selected_staging_dir, install_dir, host) + try: + ensure_converter_scripts(install_dir, llama_tag) + except Exception as exc: + log( + "converter script fetch failed after activation; install remains valid " + f"({textwrap.shorten(str(exc), width = 200, placeholder = '...')})" + ) + except PrebuiltFallback as exc: + log("prebuilt install path failed; falling back to source build") + log(f"prebuilt fallback reason: {exc}") + report = collect_system_report(host, choice, install_dir) + print(report) + raise SystemExit(EXIT_FALLBACK) from exc + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description = "Install and validate a prebuilt llama.cpp bundle for Unsloth Studio." + ) + parser.add_argument("--install-dir", help = "Target ~/.unsloth/llama.cpp directory") + parser.add_argument( + "--llama-tag", + default = DEFAULT_LLAMA_TAG, + help = f"llama.cpp release tag. Prebuilt installs are pinned to the approved tag {APPROVED_PREBUILT_LLAMA_TAG}.", + ) + parser.add_argument( + "--published-repo", + default = DEFAULT_PUBLISHED_REPO, + help = "Published bundle repository", + ) + parser.add_argument( + "--published-release-tag", + default = DEFAULT_PUBLISHED_TAG, + help = "Published GitHub release tag to pin. By default, scan releases until a compatible llama.cpp bundle is found.", + ) + resolve_group = parser.add_mutually_exclusive_group() + resolve_group.add_argument( + "--resolve-llama-tag", + nargs = "?", + const = "latest", + help = "Resolve a llama.cpp tag such as 'latest' to the logical upstream release tag.", + ) + resolve_group.add_argument( + "--resolve-install-tag", + nargs = "?", + const = "latest", + help = "Resolve a llama.cpp tag such as 'latest' to the concrete tag installable on the current host.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.resolve_llama_tag is not None: + print(resolve_requested_llama_tag(args.resolve_llama_tag)) + return EXIT_SUCCESS + + if args.resolve_install_tag is not None: + print( + resolve_requested_install_tag( + args.resolve_install_tag, args.published_release_tag or "" + ) + ) + return EXIT_SUCCESS + + if not args.install_dir: + raise SystemExit( + "install_llama_prebuilt.py: --install-dir is required unless --resolve-llama-tag or --resolve-install-tag is used" + ) + install_prebuilt( + install_dir = Path(args.install_dir).expanduser().resolve(), + llama_tag = args.llama_tag, + published_repo = args.published_repo, + published_release_tag = args.published_release_tag or "", + ) + return EXIT_SUCCESS + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SystemExit: + raise + except Exception as exc: + message = textwrap.shorten(str(exc), width = 400, placeholder = "...") + log(f"fatal helper error: {message}") + raise SystemExit(EXIT_ERROR) diff --git a/studio/setup.ps1 b/studio/setup.ps1 index c58bcd5c8d..d8465fd039 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -503,7 +503,6 @@ if ($DriverMaxCuda) { $isCompat = ($tkMaj -lt $drMajorCuda) -or ($tkMaj -eq $drMajorCuda -and $tkMin -le $drMinorCuda) if ($isCompat) { # Also verify the toolkit supports our GPU architecture - Write-Host " [DEBUG] Checking CUDA compatibility: toolkit=$tkMaj.$tkMin arch=sm_$CudaArch" -ForegroundColor Magenta $archOk = $true if ($CudaArch) { $archOk = Test-NvccArchSupport -NvccExe $candidateNvcc -Arch $CudaArch @@ -1296,6 +1295,93 @@ if ($LASTEXITCODE -ne 0) { $ErrorActionPreference = $prevEAP_t5 Write-Host "[OK] Transformers 5.x pre-installed to .venv_t5/" -ForegroundColor Green +# ========================================================================== +# PHASE 3.4: Prefer prebuilt llama.cpp bundles before source build +# ========================================================================== +$UnslothHome = Join-Path $env:USERPROFILE ".unsloth" +if (-not (Test-Path $UnslothHome)) { New-Item -ItemType Directory -Force $UnslothHome | Out-Null } +$LlamaCppDir = Join-Path $UnslothHome "llama.cpp" +$NeedLlamaSourceBuild = $false +$SkipPrebuiltInstall = $false +$RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { "latest" } +$HelperReleaseRepo = if ($env:UNSLOTH_LLAMA_RELEASE_REPO) { $env:UNSLOTH_LLAMA_RELEASE_REPO } else { "unslothai/llama.cpp" } +$resolveOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-install-tag $RequestedLlamaTag --published-repo $HelperReleaseRepo 2>&1 +$resolveExit = $LASTEXITCODE +$ResolvedLlamaTag = if ($resolveOutput) { ($resolveOutput | Select-Object -Last 1).ToString().Trim() } else { "" } +if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) { + Write-Host "" + Write-Host "[WARN] Failed to resolve an installable prebuilt llama.cpp tag via $HelperReleaseRepo" -ForegroundColor Yellow + if ($resolveOutput) { + $resolveOutput | ForEach-Object { Write-Host $_ } + } + $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-llama-tag $RequestedLlamaTag 2>$null + $fallbackExit = $LASTEXITCODE + $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { + ($fallbackOutput | Select-Object -Last 1).ToString().Trim() + } elseif ($RequestedLlamaTag -eq "latest") { + # Try Unsloth release repo first, then fall back to ggml-org upstream + $resolvedLatest = $null + try { + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/$HelperReleaseRepo/releases/latest" -ErrorAction Stop + $resolvedLatest = $latestRelease.tag_name + } catch {} + if (-not $resolvedLatest) { + try { + $latestRelease = Invoke-RestMethod -Uri "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest" -ErrorAction Stop + $resolvedLatest = $latestRelease.tag_name + } catch {} + } + if ($resolvedLatest) { $resolvedLatest } else { $RequestedLlamaTag } + } else { + $RequestedLlamaTag + } + $NeedLlamaSourceBuild = $true + $SkipPrebuiltInstall = $true +} + +Write-Host "" +Write-Host "Resolved llama.cpp release tag: $ResolvedLlamaTag" -ForegroundColor Gray + +if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { + Write-Host "" + Write-Host "[WARN] UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt llama.cpp install" -ForegroundColor Yellow + $NeedLlamaSourceBuild = $true +} else { + Write-Host "" + Write-Host "Installing prebuilt llama.cpp bundle (preferred path)..." -ForegroundColor Cyan + if (Test-Path $LlamaCppDir) { + Write-Host "Existing llama.cpp install detected -- validating staged prebuilt update before replacement" -ForegroundColor Gray + } + if ($SkipPrebuiltInstall) { + Write-Host "[WARN] Skipping prebuilt install because prebuilt tag resolution failed -- falling back to source build" -ForegroundColor Yellow + } else { + $prebuiltArgs = @( + "$PSScriptRoot\install_llama_prebuilt.py", + "--install-dir", $LlamaCppDir, + "--llama-tag", $ResolvedLlamaTag, + "--published-repo", $HelperReleaseRepo + ) + if ($env:UNSLOTH_LLAMA_RELEASE_TAG) { + $prebuiltArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG) + } + $prevEAPPrebuilt = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & python @prebuiltArgs + $prebuiltExit = $LASTEXITCODE + $ErrorActionPreference = $prevEAPPrebuilt + + if ($prebuiltExit -eq 0) { + Write-Host "[OK] Prebuilt llama.cpp installed and validated" -ForegroundColor Green + } else { + if (Test-Path $LlamaCppDir) { + Write-Host "[WARN] Prebuilt update failed; existing install was restored or cleaned before source build fallback" -ForegroundColor Yellow + } + Write-Host "[WARN] Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" -ForegroundColor Yellow + $NeedLlamaSourceBuild = $true + } + } +} + # ========================================================================== # PHASE 3.5: Install OpenSSL dev (for HTTPS support in llama-server) # ========================================================================== @@ -1303,42 +1389,46 @@ Write-Host "[OK] Transformers 5.x pre-installed to .venv_t5/" -ForegroundColor G # ShiningLight.OpenSSL.Dev includes headers + libs that cmake can find. $OpenSslAvailable = $false -# Check if OpenSSL dev is already installed (look for include dir) -$OpenSslRoots = @( - 'C:\Program Files\OpenSSL-Win64', - 'C:\Program Files\OpenSSL', - 'C:\OpenSSL-Win64' -) -$OpenSslRoot = $null -foreach ($root in $OpenSslRoots) { - if (Test-Path (Join-Path $root 'include\openssl\ssl.h')) { - $OpenSslRoot = $root - break - } -} - -if ($OpenSslRoot) { - $OpenSslAvailable = $true - Write-Host "[OK] OpenSSL dev found at $OpenSslRoot" -ForegroundColor Green -} else { - Write-Host "" - Write-Host "Installing OpenSSL dev (for HTTPS in llama-server)..." -ForegroundColor Cyan - $HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue) - if ($HasWinget) { - winget install -e --id ShiningLight.OpenSSL.Dev --accept-package-agreements --accept-source-agreements - # Re-check after install - foreach ($root in $OpenSslRoots) { - if (Test-Path (Join-Path $root 'include\openssl\ssl.h')) { - $OpenSslRoot = $root - $OpenSslAvailable = $true - Write-Host "[OK] OpenSSL dev installed at $OpenSslRoot" -ForegroundColor Green - break - } +if ($NeedLlamaSourceBuild) { + # Check if OpenSSL dev is already installed (look for include dir) + $OpenSslRoots = @( + 'C:\Program Files\OpenSSL-Win64', + 'C:\Program Files\OpenSSL', + 'C:\OpenSSL-Win64' + ) + $OpenSslRoot = $null + foreach ($root in $OpenSslRoots) { + if (Test-Path (Join-Path $root 'include\openssl\ssl.h')) { + $OpenSslRoot = $root + break } } - if (-not $OpenSslAvailable) { - Write-Host "[WARN] OpenSSL dev not available -- llama-server will be built without HTTPS" -ForegroundColor Yellow + + if ($OpenSslRoot) { + $OpenSslAvailable = $true + Write-Host "[OK] OpenSSL dev found at $OpenSslRoot" -ForegroundColor Green + } else { + Write-Host "" + Write-Host "Installing OpenSSL dev (for HTTPS in llama-server)..." -ForegroundColor Cyan + $HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue) + if ($HasWinget) { + winget install -e --id ShiningLight.OpenSSL.Dev --accept-package-agreements --accept-source-agreements + # Re-check after install + foreach ($root in $OpenSslRoots) { + if (Test-Path (Join-Path $root 'include\openssl\ssl.h')) { + $OpenSslRoot = $root + $OpenSslAvailable = $true + Write-Host "[OK] OpenSSL dev installed at $OpenSslRoot" -ForegroundColor Green + break + } + } + } + if (-not $OpenSslAvailable) { + Write-Host "[WARN] OpenSSL dev not available -- llama-server will be built without HTTPS" -ForegroundColor Yellow + } } +} else { + Write-Host "[SKIP] OpenSSL dev install -- prebuilt llama.cpp already validated" -ForegroundColor Yellow } # ========================================================================== @@ -1351,9 +1441,7 @@ if ($OpenSslRoot) { # - llama-server: for GGUF model inference (with HTTPS if OpenSSL available) # - llama-quantize: for GGUF export quantization # Prerequisites (git, cmake, VS Build Tools, CUDA Toolkit) already installed in Phase 1. -$UnslothHome = Join-Path $env:USERPROFILE ".unsloth" -if (-not (Test-Path $UnslothHome)) { New-Item -ItemType Directory -Force $UnslothHome | Out-Null } -$LlamaCppDir = Join-Path $UnslothHome "llama.cpp" +$OriginalLlamaCppDir = $LlamaCppDir $BuildDir = Join-Path $LlamaCppDir "build" $LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe" @@ -1376,7 +1464,10 @@ if (Test-Path $LlamaServerBin) { } } -if ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { +if (-not $NeedLlamaSourceBuild) { + Write-Host "" + Write-Host "[OK] Using validated prebuilt llama.cpp install at $LlamaCppDir" -ForegroundColor Green +} elseif ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { Write-Host "" Write-Host "[OK] llama-server already exists at $LlamaServerBin" -ForegroundColor Green } elseif (-not $HasCmakeForBuild) { @@ -1432,29 +1523,49 @@ if ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { # -- Step A: Clone or pull llama.cpp -- + $UseConcreteRef = ($ResolvedLlamaTag -ne "latest" -and -not [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) + if (Test-Path (Join-Path $LlamaCppDir ".git")) { - Write-Host " llama.cpp repo already cloned, pulling latest..." -ForegroundColor Gray - git -C $LlamaCppDir pull 2>&1 | Out-Null + Write-Host " Syncing llama.cpp to $ResolvedLlamaTag..." -ForegroundColor Gray + if ($UseConcreteRef) { + git -C $LlamaCppDir fetch --depth 1 origin $ResolvedLlamaTag 2>&1 | Out-Null + } else { + git -C $LlamaCppDir fetch --depth 1 origin 2>&1 | Out-Null + } if ($LASTEXITCODE -ne 0) { - Write-Host " [WARN] git pull failed -- using existing source" -ForegroundColor Yellow + Write-Host " [WARN] git fetch failed -- using existing source" -ForegroundColor Yellow + } else { + git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + $BuildOk = $false + $FailedStep = "git checkout" + } else { + git -C $LlamaCppDir clean -fdx 2>&1 | Out-Null + } } } else { - Write-Host " Cloning llama.cpp..." -ForegroundColor Gray - if (Test-Path $LlamaCppDir) { Remove-Item -Recurse -Force $LlamaCppDir } - git clone --depth 1 https://github.com/ggml-org/llama.cpp.git $LlamaCppDir 2>&1 | Out-Null + Write-Host " Cloning llama.cpp @ $ResolvedLlamaTag..." -ForegroundColor Gray + $buildTmp = "$LlamaCppDir.build.$PID" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + $cloneArgs = @("clone", "--depth", "1") + if ($UseConcreteRef) { + $cloneArgs += @("--branch", $ResolvedLlamaTag) + } + $cloneArgs += @("https://github.com/ggml-org/llama.cpp.git", $buildTmp) + git @cloneArgs 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { $BuildOk = $false $FailedStep = "git clone" + if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp } + } + # Use temp dir for build; swap into $LlamaCppDir only after build succeeds + if ($BuildOk) { + $LlamaCppDir = $buildTmp + $BuildDir = Join-Path $LlamaCppDir "build" } } # -- Step B: cmake configure -- - # Clean stale CMake cache to prevent previous CUDA settings from leaking - # into a CPU-only rebuild (or vice versa). - $CmakeCacheFile = Join-Path $BuildDir "CMakeCache.txt" - if (Test-Path $CmakeCacheFile) { - Remove-Item -Recurse -Force $BuildDir - } if ($BuildOk) { Write-Host "" @@ -1555,6 +1666,21 @@ if ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { } } + # Swap temp build dir into final location (only if we built in a temp dir) + if ($BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) { + if (Test-Path $OriginalLlamaCppDir) { Remove-Item -Recurse -Force $OriginalLlamaCppDir } + Move-Item $LlamaCppDir $OriginalLlamaCppDir + $LlamaCppDir = $OriginalLlamaCppDir + $BuildDir = Join-Path $LlamaCppDir "build" + $LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe" + } elseif (-not $BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) { + # Build failed -- clean up temp dir, preserve existing install + if (Test-Path $LlamaCppDir) { Remove-Item -Recurse -Force $LlamaCppDir } + $LlamaCppDir = $OriginalLlamaCppDir + $BuildDir = Join-Path $LlamaCppDir "build" + $LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe" + } + # Restore ErrorActionPreference $ErrorActionPreference = $prevEAP diff --git a/studio/setup.sh b/studio/setup.sh index 0e99173755..4cfabec95e 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -341,10 +341,98 @@ else echo "✅ Python dependencies up to date — skipping" fi -# ── 7. WSL: pre-install GGUF build dependencies ── +# ── 7. Prefer prebuilt llama.cpp bundles before any source build path ── +UNSLOTH_HOME="$HOME/.unsloth" +mkdir -p "$UNSLOTH_HOME" +LLAMA_CPP_DIR="$UNSLOTH_HOME/llama.cpp" +LLAMA_SERVER_BIN="$LLAMA_CPP_DIR/build/bin/llama-server" +_NEED_LLAMA_SOURCE_BUILD=false +_LLAMA_FORCE_COMPILE="${UNSLOTH_LLAMA_FORCE_COMPILE:-0}" +_REQUESTED_LLAMA_TAG="${UNSLOTH_LLAMA_TAG:-latest}" +_HELPER_RELEASE_REPO="${UNSLOTH_LLAMA_RELEASE_REPO:-unslothai/llama.cpp}" +_RESOLVE_LLAMA_LOG="$(mktemp)" +set +e +python "$SCRIPT_DIR/install_llama_prebuilt.py" \ + --resolve-install-tag "$_REQUESTED_LLAMA_TAG" \ + --published-repo "$_HELPER_RELEASE_REPO" >"$_RESOLVE_LLAMA_LOG" 2>&1 +_RESOLVE_LLAMA_STATUS=$? +set -e +if [ "$_RESOLVE_LLAMA_STATUS" -eq 0 ]; then + _RESOLVED_LLAMA_TAG="$(tail -n 1 "$_RESOLVE_LLAMA_LOG" | tr -d '\r')" +else + _RESOLVED_LLAMA_TAG="" +fi +if [ -z "$_RESOLVED_LLAMA_TAG" ]; then + echo "" + echo "⚠️ Failed to resolve an installable prebuilt llama.cpp tag via $_HELPER_RELEASE_REPO" + cat "$_RESOLVE_LLAMA_LOG" >&2 || true + set +e + _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" --resolve-llama-tag "$_REQUESTED_LLAMA_TAG" 2>/dev/null)" + _RESOLVE_UPSTREAM_STATUS=$? + set -e + if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then + if [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then + # Try Unsloth release repo first, then fall back to ggml-org upstream + _RESOLVED_LLAMA_TAG="$(curl -fsSL "https://api.github.com/repos/${_HELPER_RELEASE_REPO}/releases/latest" 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" + if [ -z "$_RESOLVED_LLAMA_TAG" ]; then + _RESOLVED_LLAMA_TAG="$(curl -fsSL https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" + fi + fi + if [ -z "$_RESOLVED_LLAMA_TAG" ]; then + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + fi + fi + _NEED_LLAMA_SOURCE_BUILD=true + _SKIP_PREBUILT_INSTALL=true +fi +rm -f "$_RESOLVE_LLAMA_LOG" + +echo "" +echo "Resolved llama.cpp release tag: $_RESOLVED_LLAMA_TAG" + +if [ "$_LLAMA_FORCE_COMPILE" = "1" ]; then + echo "" + echo "⚠️ UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt llama.cpp install" + _NEED_LLAMA_SOURCE_BUILD=true +else + echo "" + echo "Installing prebuilt llama.cpp bundle (preferred path)..." + if [ -d "$LLAMA_CPP_DIR" ]; then + echo "Existing llama.cpp install detected -- validating staged prebuilt update before replacement" + fi + if [ "${_SKIP_PREBUILT_INSTALL:-false}" = true ]; then + echo "⚠️ Skipping prebuilt install because prebuilt tag resolution failed -- falling back to source build" + else + _PREBUILT_CMD=( + python "$SCRIPT_DIR/install_llama_prebuilt.py" + --install-dir "$LLAMA_CPP_DIR" + --llama-tag "$_RESOLVED_LLAMA_TAG" + --published-repo "$_HELPER_RELEASE_REPO" + ) + if [ -n "${UNSLOTH_LLAMA_RELEASE_TAG:-}" ]; then + _PREBUILT_CMD+=(--published-release-tag "$UNSLOTH_LLAMA_RELEASE_TAG") + fi + set +e + "${_PREBUILT_CMD[@]}" + _PREBUILT_STATUS=$? + set -e + + if [ "$_PREBUILT_STATUS" -eq 0 ]; then + echo "✅ Prebuilt llama.cpp installed and validated" + else + if [ -d "$LLAMA_CPP_DIR" ]; then + echo "⚠️ Prebuilt update failed; existing install was restored or cleaned before source build fallback" + fi + echo "⚠️ Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" + _NEED_LLAMA_SOURCE_BUILD=true + fi + fi +fi + +# ── 8. WSL: pre-install GGUF build dependencies for fallback source builds ── # On WSL, sudo requires a password and can't be entered during GGUF export # (runs in a non-interactive subprocess). Install build deps here instead. -if grep -qi microsoft /proc/version 2>/dev/null; then +if [ "$_NEED_LLAMA_SOURCE_BUILD" = true ] && grep -qi microsoft /proc/version 2>/dev/null; then echo "" echo "⚠️ WSL detected -- installing build dependencies for GGUF export..." _GGUF_DEPS="pciutils build-essential cmake curl git libcurl4-openssl-dev" @@ -402,22 +490,19 @@ if grep -qi microsoft /proc/version 2>/dev/null; then fi fi -# ── 8. Build llama.cpp binaries for GGUF inference + export ── +# ── 9. Build llama.cpp binaries for GGUF inference + export when prebuilt install fails ── # Builds at ~/.unsloth/llama.cpp — a single shared location under the user's # home directory. This is used by both the inference server and the GGUF # export pipeline (unsloth-zoo). # - llama-server: for GGUF model inference # - llama-quantize: for GGUF export quantization (symlinked to root for check_llama_cpp()) -UNSLOTH_HOME="$HOME/.unsloth" -mkdir -p "$UNSLOTH_HOME" -LLAMA_CPP_DIR="$UNSLOTH_HOME/llama.cpp" -LLAMA_SERVER_BIN="$LLAMA_CPP_DIR/build/bin/llama-server" -if [ "${_SKIP_GGUF_BUILD:-}" = true ]; then +if [ "$_NEED_LLAMA_SOURCE_BUILD" = false ]; then + : +elif [ "${_SKIP_GGUF_BUILD:-}" = true ]; then echo "" echo "Skipping llama-server build (missing dependencies)" echo " Install the missing packages and re-run setup to enable GGUF inference." else -rm -rf "$LLAMA_CPP_DIR" { # Check prerequisites if ! command -v cmake &>/dev/null; then @@ -432,7 +517,13 @@ rm -rf "$LLAMA_CPP_DIR" echo "Building llama-server for GGUF inference..." BUILD_OK=true - run_quiet_no_exit "clone llama.cpp" git clone --depth 1 https://github.com/ggml-org/llama.cpp.git "$LLAMA_CPP_DIR" || BUILD_OK=false + _CLONE_BRANCH_ARGS=() + if [ "$_RESOLVED_LLAMA_TAG" != "latest" ] && [ -n "$_RESOLVED_LLAMA_TAG" ]; then + _CLONE_BRANCH_ARGS=(--branch "$_RESOLVED_LLAMA_TAG") + fi + _BUILD_TMP="${LLAMA_CPP_DIR}.build.$$" + rm -rf "$_BUILD_TMP" + run_quiet_no_exit "clone llama.cpp" git clone --depth 1 "${_CLONE_BRANCH_ARGS[@]}" https://github.com/ggml-org/llama.cpp.git "$_BUILD_TMP" || BUILD_OK=false if [ "$BUILD_OK" = true ]; then # Skip tests/examples we don't need (faster build) @@ -571,21 +662,29 @@ rm -rf "$LLAMA_CPP_DIR" CMAKE_GENERATOR_ARGS="-G Ninja" fi - run_quiet_no_exit "cmake llama.cpp" cmake $CMAKE_GENERATOR_ARGS -S "$LLAMA_CPP_DIR" -B "$LLAMA_CPP_DIR/build" $CMAKE_ARGS || BUILD_OK=false + run_quiet_no_exit "cmake llama.cpp" cmake $CMAKE_GENERATOR_ARGS -S "$_BUILD_TMP" -B "$_BUILD_TMP/build" $CMAKE_ARGS || BUILD_OK=false fi if [ "$BUILD_OK" = true ]; then - run_quiet_no_exit "build llama-server" cmake --build "$LLAMA_CPP_DIR/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false + run_quiet_no_exit "build llama-server" cmake --build "$_BUILD_TMP/build" --config Release --target llama-server -j"$NCPU" || BUILD_OK=false fi # Also build llama-quantize (needed by unsloth-zoo's GGUF export pipeline) if [ "$BUILD_OK" = true ]; then - run_quiet_no_exit "build llama-quantize" cmake --build "$LLAMA_CPP_DIR/build" --config Release --target llama-quantize -j"$NCPU" || true - # Symlink to llama.cpp root — check_llama_cpp() looks for the binary there + run_quiet_no_exit "build llama-quantize" cmake --build "$_BUILD_TMP/build" --config Release --target llama-quantize -j"$NCPU" || true + fi + + # Swap only after build succeeds -- preserves existing install on failure + if [ "$BUILD_OK" = true ]; then + rm -rf "$LLAMA_CPP_DIR" + mv "$_BUILD_TMP" "$LLAMA_CPP_DIR" + # Symlink to llama.cpp root -- check_llama_cpp() looks for the binary there QUANTIZE_BIN="$LLAMA_CPP_DIR/build/bin/llama-quantize" if [ -f "$QUANTIZE_BIN" ]; then ln -sf build/bin/llama-quantize "$LLAMA_CPP_DIR/llama-quantize" fi + else + rm -rf "$_BUILD_TMP" fi if [ "$BUILD_OK" = true ]; then diff --git a/tests/studio/install/smoke_test_llama_prebuilt.py b/tests/studio/install/smoke_test_llama_prebuilt.py new file mode 100644 index 0000000000..994757d2e2 --- /dev/null +++ b/tests/studio/install/smoke_test_llama_prebuilt.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import importlib.util +import shutil +import sys +import tempfile +import time +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +INSTALLER_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py" + + +def load_installer_module(): + spec = importlib.util.spec_from_file_location( + "studio_install_llama_prebuilt", INSTALLER_PATH + ) + if spec is None or spec.loader is None: + raise RuntimeError(f"unable to load installer module from {INSTALLER_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +installer = load_installer_module() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description = ( + "Run a real end-to-end prebuilt llama.cpp install into an isolated temporary " + "directory on the current machine." + ) + ) + parser.add_argument( + "--llama-tag", + default = "latest", + help = "llama.cpp tag to resolve. Defaults to the approved prebuilt tag for this host.", + ) + parser.add_argument( + "--published-repo", + default = installer.DEFAULT_PUBLISHED_REPO, + help = "Published bundle repository used for Linux CUDA selection.", + ) + parser.add_argument( + "--published-release-tag", + default = installer.DEFAULT_PUBLISHED_TAG or "", + help = "Optional published GitHub release tag to pin.", + ) + parser.add_argument( + "--work-dir", + default = "", + help = ( + "Optional directory under which the smoke install temp dir will be created. " + "If omitted, defaults to ./.tmp/llama-prebuilt-smoke under the current directory." + ), + ) + parser.add_argument( + "--keep-temp", + action = "store_true", + help = "Keep the temporary smoke install directory after success.", + ) + return parser.parse_args() + + +def smoke_root_base(work_dir: str) -> Path: + if work_dir: + return Path(work_dir).expanduser().resolve() + return (Path.cwd() / ".tmp" / "llama-prebuilt-smoke").resolve() + + +def make_smoke_root(base_dir: Path) -> Path: + base_dir.mkdir(parents = True, exist_ok = True) + timestamp = time.strftime("%Y%m%d%H%M%S", time.gmtime()) + return Path(tempfile.mkdtemp(prefix = f"run-{timestamp}-", dir = base_dir)) + + +def main() -> int: + args = parse_args() + host = installer.detect_host() + smoke_base = smoke_root_base(args.work_dir) + smoke_root = make_smoke_root(smoke_base) + install_dir = smoke_root / "install" / "llama.cpp" + choice = None + + print(f"[smoke] host={host.system} machine={host.machine}") + print(f"[smoke] temp_root={smoke_root}") + + try: + requested_tag, resolved_tag, attempts, _approved_checksums = ( + installer.resolve_install_attempts( + args.llama_tag, + host, + args.published_repo, + args.published_release_tag, + ) + ) + choice = attempts[0] + print(f"[smoke] requested_tag={requested_tag}") + print(f"[smoke] resolved_tag={resolved_tag}") + print(f"[smoke] selected_asset={choice.name}") + print(f"[smoke] selected_source={choice.source_label}") + print(f"[smoke] install_dir={install_dir}") + installer.install_prebuilt( + install_dir = install_dir, + llama_tag = args.llama_tag, + published_repo = args.published_repo, + published_release_tag = args.published_release_tag, + ) + print(f"[smoke] PASS install_dir={install_dir}") + print( + "[smoke] note=This was a real prebuilt install into an isolated temp directory." + ) + return installer.EXIT_SUCCESS + except SystemExit as exc: + code = int(exc.code) if isinstance(exc.code, int) else installer.EXIT_ERROR + if code == installer.EXIT_FALLBACK: + print(f"[smoke] FALLBACK install_dir={install_dir}") + print( + "[smoke] note=Prebuilt path failed and would fall back to source build in setup." + ) + print(installer.collect_system_report(host, choice, install_dir)) + else: + print(f"[smoke] ERROR exit_code={code} install_dir={install_dir}") + return code + except Exception as exc: + print(f"[smoke] ERROR {exc}") + print(installer.collect_system_report(host, choice, install_dir)) + return installer.EXIT_ERROR + finally: + if args.keep_temp: + print(f"[smoke] keeping_temp_root={smoke_root}") + elif smoke_root.exists(): + shutil.rmtree(smoke_root, ignore_errors = True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/studio/install/test_install_llama_prebuilt_logic.py b/tests/studio/install/test_install_llama_prebuilt_logic.py new file mode 100644 index 0000000000..eb30ac2745 --- /dev/null +++ b/tests/studio/install/test_install_llama_prebuilt_logic.py @@ -0,0 +1,630 @@ +import importlib.util +import io +import json +import os +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + + +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py" +SPEC = importlib.util.spec_from_file_location( + "studio_install_llama_prebuilt", MODULE_PATH +) +assert SPEC is not None and SPEC.loader is not None +INSTALL_LLAMA_PREBUILT = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = INSTALL_LLAMA_PREBUILT +SPEC.loader.exec_module(INSTALL_LLAMA_PREBUILT) + +PrebuiltFallback = INSTALL_LLAMA_PREBUILT.PrebuiltFallback +extract_archive = INSTALL_LLAMA_PREBUILT.extract_archive +binary_env = INSTALL_LLAMA_PREBUILT.binary_env +HostInfo = INSTALL_LLAMA_PREBUILT.HostInfo +AssetChoice = INSTALL_LLAMA_PREBUILT.AssetChoice +ApprovedArtifactHash = INSTALL_LLAMA_PREBUILT.ApprovedArtifactHash +ApprovedReleaseChecksums = INSTALL_LLAMA_PREBUILT.ApprovedReleaseChecksums +hydrate_source_tree = INSTALL_LLAMA_PREBUILT.hydrate_source_tree +validate_prebuilt_choice = INSTALL_LLAMA_PREBUILT.validate_prebuilt_choice +activate_install_tree = INSTALL_LLAMA_PREBUILT.activate_install_tree +create_install_staging_dir = INSTALL_LLAMA_PREBUILT.create_install_staging_dir +sha256_file = INSTALL_LLAMA_PREBUILT.sha256_file +source_archive_logical_name = INSTALL_LLAMA_PREBUILT.source_archive_logical_name + + +def approved_checksums_for( + upstream_tag: str, *, source_archive: Path, bundle_archive: Path, bundle_name: str +) -> ApprovedReleaseChecksums: + return ApprovedReleaseChecksums( + repo = "local", + release_tag = upstream_tag, + upstream_tag = upstream_tag, + source_commit = None, + artifacts = { + source_archive_logical_name(upstream_tag): ApprovedArtifactHash( + asset_name = source_archive_logical_name(upstream_tag), + sha256 = sha256_file(source_archive), + repo = "ggml-org/llama.cpp", + kind = "upstream-source", + ), + bundle_name: ApprovedArtifactHash( + asset_name = bundle_name, + sha256 = sha256_file(bundle_archive), + repo = "local", + kind = "local-test-bundle", + ), + }, + ) + + +def test_extract_archive_allows_safe_tar_symlink_chain(tmp_path: Path): + archive_path = tmp_path / "bundle.tar.gz" + payload = b"shared-object" + + with tarfile.open(archive_path, "w:gz") as archive: + versioned = tarfile.TarInfo("libllama.so.0.0.1") + versioned.size = len(payload) + archive.addfile(versioned, io_bytes(payload)) + + soname = tarfile.TarInfo("libllama.so.0") + soname.type = tarfile.SYMTYPE + soname.linkname = "libllama.so.0.0.1" + archive.addfile(soname) + + linker_name = tarfile.TarInfo("libllama.so") + linker_name.type = tarfile.SYMTYPE + linker_name.linkname = "libllama.so.0" + archive.addfile(linker_name) + + destination = tmp_path / "extract" + extract_archive(archive_path, destination) + + assert (destination / "libllama.so.0.0.1").read_bytes() == payload + assert (destination / "libllama.so.0").is_symlink() + assert (destination / "libllama.so").is_symlink() + assert (destination / "libllama.so").resolve().read_bytes() == payload + + +def test_extract_archive_allows_safe_tar_hardlink(tmp_path: Path): + archive_path = tmp_path / "bundle.tar.gz" + payload = b"quantize" + + with tarfile.open(archive_path, "w:gz") as archive: + target = tarfile.TarInfo("llama-quantize") + target.size = len(payload) + archive.addfile(target, io_bytes(payload)) + + hardlink = tarfile.TarInfo("llama-quantize-copy") + hardlink.type = tarfile.LNKTYPE + hardlink.linkname = "llama-quantize" + archive.addfile(hardlink) + + destination = tmp_path / "extract" + extract_archive(archive_path, destination) + + assert (destination / "llama-quantize-copy").read_bytes() == payload + assert not (destination / "llama-quantize-copy").is_symlink() + + +def test_extract_archive_rejects_absolute_tar_symlink_target(tmp_path: Path): + archive_path = tmp_path / "bundle.tar.gz" + + with tarfile.open(archive_path, "w:gz") as archive: + entry = tarfile.TarInfo("libllama.so") + entry.type = tarfile.SYMTYPE + entry.linkname = "/tmp/libllama.so.0" + archive.addfile(entry) + + with pytest.raises(PrebuiltFallback, match = "archive link used an absolute target"): + extract_archive(archive_path, tmp_path / "extract") + + +def test_extract_archive_rejects_escaping_tar_symlink_target(tmp_path: Path): + archive_path = tmp_path / "bundle.tar.gz" + + with tarfile.open(archive_path, "w:gz") as archive: + entry = tarfile.TarInfo("libllama.so") + entry.type = tarfile.SYMTYPE + entry.linkname = "../outside/libllama.so.0" + archive.addfile(entry) + + with pytest.raises(PrebuiltFallback, match = "archive link escaped destination"): + extract_archive(archive_path, tmp_path / "extract") + + +def test_extract_archive_rejects_unresolved_tar_symlink_target(tmp_path: Path): + archive_path = tmp_path / "bundle.tar.gz" + + with tarfile.open(archive_path, "w:gz") as archive: + entry = tarfile.TarInfo("libllama.so") + entry.type = tarfile.SYMTYPE + entry.linkname = "libllama.so.0" + archive.addfile(entry) + + with pytest.raises(PrebuiltFallback, match = "unresolved link entries"): + extract_archive(archive_path, tmp_path / "extract") + + +def test_extract_archive_rejects_zip_symlink_entry(tmp_path: Path): + archive_path = tmp_path / "bundle.zip" + + with zipfile.ZipFile(archive_path, "w") as archive: + info = zipfile.ZipInfo("libllama.so") + info.create_system = 3 + info.external_attr = 0o120777 << 16 + archive.writestr(info, "libllama.so.0") + + with pytest.raises(PrebuiltFallback, match = "zip archive contained a symlink entry"): + extract_archive(archive_path, tmp_path / "extract") + + +def test_hydrate_source_tree_extracts_upstream_archive_contents( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + upstream_tag = "b9999" + archive_path = tmp_path / "llama.cpp-source.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/CMakeLists.txt", + b"cmake_minimum_required(VERSION 3.14)\n", + ) + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/convert_hf_to_gguf.py", + b"#!/usr/bin/env python3\nimport gguf\n", + ) + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/gguf-py/gguf/__init__.py", + b"__all__ = []\n", + ) + + source_urls = set(INSTALL_LLAMA_PREBUILT.upstream_source_archive_urls(upstream_tag)) + + def fake_download_file(url: str, destination: Path) -> None: + assert url in source_urls + destination.write_bytes(archive_path.read_bytes()) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "download_file", fake_download_file) + + install_dir = tmp_path / "install" + work_dir = tmp_path / "work" + work_dir.mkdir() + hydrate_source_tree( + upstream_tag, install_dir, work_dir, expected_sha256 = sha256_file(archive_path) + ) + + assert (install_dir / "CMakeLists.txt").exists() + assert (install_dir / "convert_hf_to_gguf.py").exists() + assert (install_dir / "gguf-py" / "gguf" / "__init__.py").exists() + assert not (install_dir / f"llama.cpp-{upstream_tag}").exists() + + +def test_validate_prebuilt_choice_creates_repo_shaped_linux_install( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + upstream_tag = "b9998" + bundle_name = "app-b9998-linux-x64-cuda13-newer.tar.gz" + source_archive = tmp_path / "source.tar.gz" + bundle_archive = tmp_path / "bundle.tar.gz" + with tarfile.open(source_archive, "w:gz") as archive: + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/CMakeLists.txt", + b"cmake_minimum_required(VERSION 3.14)\n", + ) + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/convert_hf_to_gguf.py", + b"#!/usr/bin/env python3\nimport gguf\n", + ) + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/gguf-py/gguf/__init__.py", + b"__all__ = []\n", + ) + with tarfile.open(bundle_archive, "w:gz") as archive: + add_bytes_to_tar(archive, "llama-server", b"#!/bin/sh\nexit 0\n", mode = 0o755) + add_bytes_to_tar(archive, "llama-quantize", b"#!/bin/sh\nexit 0\n", mode = 0o755) + add_bytes_to_tar(archive, "libllama.so.0.0.1", b"libllama") + add_symlink_to_tar(archive, "libllama.so.0", "libllama.so.0.0.1") + add_symlink_to_tar(archive, "libllama.so", "libllama.so.0") + add_bytes_to_tar(archive, "libggml.so.0.9.8", b"libggml") + add_symlink_to_tar(archive, "libggml.so.0", "libggml.so.0.9.8") + add_symlink_to_tar(archive, "libggml.so", "libggml.so.0") + add_bytes_to_tar(archive, "libggml-base.so.0.9.8", b"libggml-base") + add_symlink_to_tar(archive, "libggml-base.so.0", "libggml-base.so.0.9.8") + add_symlink_to_tar(archive, "libggml-base.so", "libggml-base.so.0") + add_bytes_to_tar(archive, "libggml-cpu-x64.so.0.9.8", b"libggml-cpu") + add_symlink_to_tar(archive, "libggml-cpu-x64.so.0", "libggml-cpu-x64.so.0.9.8") + add_symlink_to_tar(archive, "libggml-cpu-x64.so", "libggml-cpu-x64.so.0") + add_bytes_to_tar(archive, "libmtmd.so.0.0.1", b"libmtmd") + add_symlink_to_tar(archive, "libmtmd.so.0", "libmtmd.so.0.0.1") + add_symlink_to_tar(archive, "libmtmd.so", "libmtmd.so.0") + add_bytes_to_tar(archive, "BUILD_INFO.txt", b"bundle metadata\n") + add_bytes_to_tar(archive, "THIRD_PARTY_LICENSES.txt", b"licenses\n") + + source_urls = set(INSTALL_LLAMA_PREBUILT.upstream_source_archive_urls(upstream_tag)) + + def fake_download_file(url: str, destination: Path) -> None: + if url in source_urls: + destination.write_bytes(source_archive.read_bytes()) + return + if url == "file://bundle": + destination.write_bytes(bundle_archive.read_bytes()) + return + raise AssertionError(f"unexpected download url: {url}") + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "download_file", fake_download_file) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "download_bytes", + lambda url, **_: b"#!/usr/bin/env python3\nimport gguf\n", + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "preflight_linux_installed_binaries", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "validate_quantize", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "validate_server", lambda *args, **kwargs: None + ) + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "local", + tag = upstream_tag, + name = bundle_name, + url = "file://bundle", + source_label = "local", + is_ready_bundle = True, + install_kind = "linux-cuda", + bundle_profile = "cuda13-newer", + runtime_line = "cuda13", + expected_sha256 = sha256_file(bundle_archive), + ) + + install_dir = tmp_path / "install" + work_dir = tmp_path / "work" + work_dir.mkdir() + probe_path = tmp_path / "stories260K.gguf" + quantized_path = tmp_path / "stories260K-q4.gguf" + validate_prebuilt_choice( + choice, + host, + install_dir, + work_dir, + probe_path, + requested_tag = upstream_tag, + llama_tag = upstream_tag, + approved_checksums = approved_checksums_for( + upstream_tag, + source_archive = source_archive, + bundle_archive = bundle_archive, + bundle_name = bundle_name, + ), + prebuilt_fallback_used = False, + quantized_path = quantized_path, + ) + + assert (install_dir / "gguf-py" / "gguf" / "__init__.py").exists() + assert (install_dir / "convert_hf_to_gguf.py").exists() + assert (install_dir / "build" / "bin" / "llama-server").exists() + assert (install_dir / "build" / "bin" / "llama-quantize").exists() + assert (install_dir / "build" / "bin" / "libllama.so").exists() + assert (install_dir / "llama-server").exists() + assert (install_dir / "llama-quantize").exists() + assert (install_dir / "UNSLOTH_PREBUILT_INFO.json").exists() + assert (install_dir / "BUILD_INFO.txt").exists() + + +def test_validate_prebuilt_choice_creates_repo_shaped_windows_install( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + upstream_tag = "b9997" + bundle_name = "app-b9997-windows-x64-cpu.zip" + source_archive = tmp_path / "source.tar.gz" + bundle_archive = tmp_path / "bundle.zip" + with tarfile.open(source_archive, "w:gz") as archive: + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/CMakeLists.txt", + b"cmake_minimum_required(VERSION 3.14)\n", + ) + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/convert_hf_to_gguf.py", + b"#!/usr/bin/env python3\nimport gguf\n", + ) + add_bytes_to_tar( + archive, + f"llama.cpp-{upstream_tag}/gguf-py/gguf/__init__.py", + b"__all__ = []\n", + ) + with zipfile.ZipFile(bundle_archive, "w") as archive: + archive.writestr("llama-server.exe", b"MZ") + archive.writestr("llama-quantize.exe", b"MZ") + archive.writestr("llama.dll", b"DLL") + archive.writestr("BUILD_INFO.txt", b"bundle metadata\n") + + source_urls = set(INSTALL_LLAMA_PREBUILT.upstream_source_archive_urls(upstream_tag)) + + def fake_download_file(url: str, destination: Path) -> None: + if url in source_urls: + destination.write_bytes(source_archive.read_bytes()) + return + if url == "file://bundle.zip": + destination.write_bytes(bundle_archive.read_bytes()) + return + raise AssertionError(f"unexpected download url: {url}") + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "download_file", fake_download_file) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "download_bytes", + lambda url, **_: b"#!/usr/bin/env python3\nimport gguf\n", + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "preflight_linux_installed_binaries", + lambda *args, **kwargs: None, + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "validate_quantize", lambda *args, **kwargs: None + ) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, "validate_server", lambda *args, **kwargs: None + ) + + host = HostInfo( + system = "Windows", + machine = "AMD64", + is_windows = True, + is_linux = False, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + choice = AssetChoice( + repo = "local", + tag = upstream_tag, + name = bundle_name, + url = "file://bundle.zip", + source_label = "local", + is_ready_bundle = True, + install_kind = "windows-cpu", + expected_sha256 = sha256_file(bundle_archive), + ) + + install_dir = tmp_path / "install" + work_dir = tmp_path / "work" + work_dir.mkdir() + probe_path = tmp_path / "stories260K.gguf" + quantized_path = tmp_path / "stories260K-q4.gguf" + validate_prebuilt_choice( + choice, + host, + install_dir, + work_dir, + probe_path, + requested_tag = upstream_tag, + llama_tag = upstream_tag, + approved_checksums = approved_checksums_for( + upstream_tag, + source_archive = source_archive, + bundle_archive = bundle_archive, + bundle_name = bundle_name, + ), + prebuilt_fallback_used = False, + quantized_path = quantized_path, + ) + + assert (install_dir / "gguf-py" / "gguf" / "__init__.py").exists() + assert (install_dir / "convert_hf_to_gguf.py").exists() + assert (install_dir / "build" / "bin" / "Release" / "llama-server.exe").exists() + assert (install_dir / "build" / "bin" / "Release" / "llama-quantize.exe").exists() + assert (install_dir / "build" / "bin" / "Release" / "llama.dll").exists() + assert not (install_dir / "llama-server.exe").exists() + assert (install_dir / "UNSLOTH_PREBUILT_INFO.json").exists() + assert (install_dir / "BUILD_INFO.txt").exists() + + +def test_activate_install_tree_restores_existing_install_after_activation_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + (install_dir / "old.txt").write_text("old install\n") + + staging_dir = create_install_staging_dir(install_dir) + (staging_dir / "new.txt").write_text("new install\n") + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "confirm_install_tree", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("activation confirm failed") + ), + ) + + with pytest.raises( + PrebuiltFallback, + match = "activation failed; restored previous install", + ): + activate_install_tree(staging_dir, install_dir, host) + + assert (install_dir / "old.txt").read_text() == "old install\n" + assert not (install_dir / "new.txt").exists() + assert not staging_dir.exists() + assert not (tmp_path / ".staging").exists() + + output = capsys.readouterr().out + assert "moving existing install to rollback path" in output + assert "restored previous install from rollback path" in output + + +def test_activate_install_tree_cleans_all_paths_when_rollback_restore_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir() + (install_dir / "old.txt").write_text("old install\n") + + staging_dir = create_install_staging_dir(install_dir) + (staging_dir / "new.txt").write_text("new install\n") + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "confirm_install_tree", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + RuntimeError("activation confirm failed") + ), + ) + + original_replace = INSTALL_LLAMA_PREBUILT.os.replace + + def flaky_replace(src, dst): + src_path = Path(src) + dst_path = Path(dst) + if "rollback-" in src_path.name and dst_path == install_dir: + raise OSError("restore failed") + return original_replace(src, dst) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT.os, "replace", flaky_replace) + + with pytest.raises( + PrebuiltFallback, + match = "activation and rollback failed; cleaned install state for fresh source build", + ): + activate_install_tree(staging_dir, install_dir, host) + + assert not install_dir.exists() + assert not staging_dir.exists() + assert not (tmp_path / ".staging").exists() + + output = capsys.readouterr().out + assert "rollback after failed activation also failed: restore failed" in output + assert ( + "cleaning staging, install, and rollback paths before source build fallback" + in output + ) + assert "removing failed install path" in output + assert "removing rollback path" in output + + +def test_binary_env_linux_includes_binary_parent_in_ld_library_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + install_dir = tmp_path / "llama.cpp" + bin_dir = install_dir / "build" / "bin" + bin_dir.mkdir(parents = True) + binary_path = bin_dir / "llama-server" + binary_path.write_bytes(b"fake") + + host = HostInfo( + system = "Linux", + machine = "x86_64", + is_windows = False, + is_linux = True, + is_macos = False, + is_x86_64 = True, + is_arm64 = False, + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "linux_runtime_dirs", lambda _bp: []) + + env = binary_env(binary_path, install_dir, host) + ld_dirs = env["LD_LIBRARY_PATH"].split(os.pathsep) + assert ( + str(bin_dir) in ld_dirs + ), f"binary_path.parent ({bin_dir}) must be in LD_LIBRARY_PATH, got: {ld_dirs}" + assert str(install_dir) in ld_dirs + + +def io_bytes(data: bytes): + return io.BytesIO(data) + + +def add_bytes_to_tar( + archive: tarfile.TarFile, name: str, data: bytes, *, mode: int = 0o644 +) -> None: + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = mode + archive.addfile(info, io_bytes(data)) + + +def add_symlink_to_tar(archive: tarfile.TarFile, name: str, target: str) -> None: + info = tarfile.TarInfo(name) + info.type = tarfile.SYMTYPE + info.linkname = target + archive.addfile(info) diff --git a/tests/studio/install/test_pr4562_bugfixes.py b/tests/studio/install/test_pr4562_bugfixes.py new file mode 100644 index 0000000000..9b8c6219de --- /dev/null +++ b/tests/studio/install/test_pr4562_bugfixes.py @@ -0,0 +1,687 @@ +""" +Comprehensive tests for PR #4562 bug fixes. + +Tests cover: + - Bug 1: PS1 detached HEAD on re-run (fetch + checkout -B pattern) + - Bug 2: Source-build fallback ignores pinned tag (both .sh and .ps1) + - Bug 3: Unix fallback deletes install before checking prerequisites + - Bug 4: Linux LD_LIBRARY_PATH missing build/bin + - "latest" tag resolution fallback chain (Unsloth -> ggml-org -> raw) + - Cross-platform binary_env (Linux, macOS, Windows) + - Edge cases: malformed JSON, empty responses, env overrides + +Run: pytest tests/studio/install/test_pr4562_bugfixes.py -v +""" + +import importlib.util +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path +from unittest.mock import patch + +import pytest + +# --------------------------------------------------------------------------- +# Load the module under test (same pattern as existing test files) +# --------------------------------------------------------------------------- +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py" +SPEC = importlib.util.spec_from_file_location( + "studio_install_llama_prebuilt", MODULE_PATH +) +assert SPEC is not None and SPEC.loader is not None +MOD = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MOD +SPEC.loader.exec_module(MOD) + +binary_env = MOD.binary_env +HostInfo = MOD.HostInfo +resolve_requested_llama_tag = MOD.resolve_requested_llama_tag + +SETUP_SH = PACKAGE_ROOT / "studio" / "setup.sh" +SETUP_PS1 = PACKAGE_ROOT / "studio" / "setup.ps1" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def make_host(*, system: str) -> HostInfo: + """Create a HostInfo for the given OS.""" + return HostInfo( + system = system, + machine = "x86_64" if system != "Darwin" else "arm64", + is_windows = (system == "Windows"), + is_linux = (system == "Linux"), + is_macos = (system == "Darwin"), + is_x86_64 = (system != "Darwin"), + is_arm64 = (system == "Darwin"), + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + visible_cuda_devices = None, + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + + +BASH = "/bin/bash" + + +def run_bash(script: str, *, timeout: int = 10, env: dict | None = None) -> str: + """Run a bash script fragment and return its stdout.""" + run_env = os.environ.copy() + if env: + run_env.update(env) + result = subprocess.run( + [BASH, "-c", script], + capture_output = True, + text = True, + timeout = timeout, + env = run_env, + ) + return result.stdout.strip() + + +# ========================================================================= +# TEST GROUP A: binary_env across all platforms (Bug 4 + cross-platform) +# ========================================================================= +class TestBinaryEnvCrossPlatform: + """Test that binary_env returns correct library paths for all OSes.""" + + def test_linux_includes_binary_parent_in_ld_library_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + install_dir = tmp_path / "llama.cpp" + bin_dir = install_dir / "build" / "bin" + bin_dir.mkdir(parents = True) + binary_path = bin_dir / "llama-server" + binary_path.write_bytes(b"fake") + + host = make_host(system = "Linux") + monkeypatch.setattr(MOD, "linux_runtime_dirs", lambda _bp: []) + + env = binary_env(binary_path, install_dir, host) + ld_dirs = env["LD_LIBRARY_PATH"].split(os.pathsep) + assert str(bin_dir) in ld_dirs, f"build/bin not in LD_LIBRARY_PATH: {ld_dirs}" + assert ( + str(install_dir) in ld_dirs + ), f"install_dir not in LD_LIBRARY_PATH: {ld_dirs}" + + def test_linux_binary_parent_comes_before_install_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """build/bin should be searched before install_dir for .so files.""" + install_dir = tmp_path / "llama.cpp" + bin_dir = install_dir / "build" / "bin" + bin_dir.mkdir(parents = True) + binary_path = bin_dir / "llama-server" + binary_path.write_bytes(b"fake") + + host = make_host(system = "Linux") + monkeypatch.setattr(MOD, "linux_runtime_dirs", lambda _bp: []) + + env = binary_env(binary_path, install_dir, host) + ld_dirs = env["LD_LIBRARY_PATH"].split(os.pathsep) + bin_idx = ld_dirs.index(str(bin_dir)) + install_idx = ld_dirs.index(str(install_dir)) + assert ( + bin_idx < install_idx + ), "binary_path.parent should come before install_dir" + + def test_linux_deduplicates_when_binary_parent_equals_install_dir( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + """When binary is directly in install_dir, no duplicate entries.""" + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir(parents = True) + binary_path = install_dir / "llama-server" + binary_path.write_bytes(b"fake") + + host = make_host(system = "Linux") + monkeypatch.setattr(MOD, "linux_runtime_dirs", lambda _bp: []) + + env = binary_env(binary_path, install_dir, host) + ld_dirs = [d for d in env["LD_LIBRARY_PATH"].split(os.pathsep) if d] + count = ld_dirs.count(str(install_dir)) + assert count == 1, f"install_dir appears {count} times in LD_LIBRARY_PATH" + + def test_linux_preserves_existing_ld_library_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + install_dir = tmp_path / "llama.cpp" + bin_dir = install_dir / "build" / "bin" + bin_dir.mkdir(parents = True) + binary_path = bin_dir / "llama-server" + binary_path.write_bytes(b"fake") + + # Create real directories so dedupe_existing_dirs keeps them + custom_lib = tmp_path / "custom_lib" + other_lib = tmp_path / "other_lib" + custom_lib.mkdir() + other_lib.mkdir() + + host = make_host(system = "Linux") + monkeypatch.setattr(MOD, "linux_runtime_dirs", lambda _bp: []) + original = os.environ.get("LD_LIBRARY_PATH", "") + os.environ["LD_LIBRARY_PATH"] = f"{custom_lib}:{other_lib}" + try: + env = binary_env(binary_path, install_dir, host) + finally: + if original: + os.environ["LD_LIBRARY_PATH"] = original + else: + os.environ.pop("LD_LIBRARY_PATH", None) + ld_dirs = env["LD_LIBRARY_PATH"].split(os.pathsep) + assert str(custom_lib.resolve()) in ld_dirs + assert str(other_lib.resolve()) in ld_dirs + + def test_windows_includes_binary_parent_in_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + install_dir = tmp_path / "llama.cpp" + bin_dir = install_dir / "build" / "bin" / "Release" + bin_dir.mkdir(parents = True) + binary_path = bin_dir / "llama-server.exe" + binary_path.write_bytes(b"MZ") + + host = make_host(system = "Windows") + monkeypatch.setattr( + MOD, "windows_runtime_dirs_for_runtime_line", lambda _rt: [] + ) + + env = binary_env(binary_path, install_dir, host) + path_dirs = env["PATH"].split(os.pathsep) + assert str(bin_dir) in path_dirs, f"build/bin/Release not in PATH: {path_dirs}" + + def test_macos_sets_dyld_library_path( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): + install_dir = tmp_path / "llama.cpp" + install_dir.mkdir(parents = True) + bin_dir = install_dir / "build" / "bin" + binary_path = bin_dir / "llama-server" + binary_path.parent.mkdir(parents = True) + binary_path.write_bytes(b"fake") + + host = make_host(system = "Darwin") + monkeypatch.delenv("DYLD_LIBRARY_PATH", raising = False) + + env = binary_env(binary_path, install_dir, host) + dyld_parts = [p for p in env["DYLD_LIBRARY_PATH"].split(os.pathsep) if p] + assert ( + str(bin_dir) in dyld_parts + ), f"build/bin not in DYLD_LIBRARY_PATH: {dyld_parts}" + assert ( + str(install_dir) in dyld_parts + ), f"install_dir not in DYLD_LIBRARY_PATH: {dyld_parts}" + # binary_path.parent (build/bin) should come before install_dir + assert dyld_parts.index(str(bin_dir)) < dyld_parts.index(str(install_dir)) + + +# ========================================================================= +# TEST GROUP B: resolve_requested_llama_tag (Python function) +# ========================================================================= +class TestResolveRequestedLlamaTag: + def test_concrete_tag_passes_through(self): + assert resolve_requested_llama_tag("b8508") == "b8508" + + def test_none_resolves_to_latest(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(MOD, "latest_upstream_release_tag", lambda: "b9999") + assert resolve_requested_llama_tag(None) == "b9999" + + def test_latest_resolves_to_upstream(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(MOD, "latest_upstream_release_tag", lambda: "b1234") + assert resolve_requested_llama_tag("latest") == "b1234" + + def test_empty_string_resolves_to_latest(self, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(MOD, "latest_upstream_release_tag", lambda: "b5555") + assert resolve_requested_llama_tag("") == "b5555" + + +# ========================================================================= +# TEST GROUP C: setup.sh logic (bash subprocess tests) +# ========================================================================= +class TestSetupShLogic: + """Test setup.sh fragments via bash subprocess with controlled PATH.""" + + def test_cmake_missing_preserves_install(self, tmp_path: Path): + """Bug 3: When cmake is missing, rm -rf should NOT run.""" + llama_dir = tmp_path / "llama.cpp" + llama_dir.mkdir() + marker = llama_dir / "marker.txt" + marker.write_text("existing") + + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir() + # Create mock git but NOT cmake + (mock_bin / "git").write_text("#!/bin/bash\nexit 0\n") + (mock_bin / "git").chmod(0o755) + + # Build PATH: mock_bin first, then system dirs WITHOUT cmake + safe_dirs = [str(mock_bin)] + for d in os.environ.get("PATH", "").split(":"): + if d and not os.path.isfile(os.path.join(d, "cmake")): + safe_dirs.append(d) + + script = textwrap.dedent(f"""\ + export LLAMA_CPP_DIR="{llama_dir}" + if ! command -v cmake &>/dev/null; then + echo "cmake_missing" + elif ! command -v git &>/dev/null; then + echo "git_missing" + else + rm -rf "$LLAMA_CPP_DIR" + echo "would_clone" + fi + """) + output = run_bash(script, env = {"PATH": ":".join(safe_dirs)}) + assert "cmake_missing" in output + assert marker.exists(), "Install dir was deleted despite cmake missing!" + + def test_git_missing_preserves_install(self, tmp_path: Path): + """Bug 3: When git is missing, rm -rf should NOT run.""" + llama_dir = tmp_path / "llama.cpp" + llama_dir.mkdir() + marker = llama_dir / "marker.txt" + marker.write_text("existing") + + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir() + # Create mock cmake but NOT git + (mock_bin / "cmake").write_text("#!/bin/bash\nexit 0\n") + (mock_bin / "cmake").chmod(0o755) + + # Build PATH: mock_bin first, then system dirs WITHOUT git + safe_dirs = [str(mock_bin)] + for d in os.environ.get("PATH", "").split(":"): + if d and not os.path.isfile(os.path.join(d, "git")): + safe_dirs.append(d) + + script = textwrap.dedent(f"""\ + export LLAMA_CPP_DIR="{llama_dir}" + if ! command -v cmake &>/dev/null; then + echo "cmake_missing" + elif ! command -v git &>/dev/null; then + echo "git_missing" + else + rm -rf "$LLAMA_CPP_DIR" + echo "would_clone" + fi + """) + output = run_bash(script, env = {"PATH": ":".join(safe_dirs)}) + assert "git_missing" in output + assert marker.exists(), "Install dir was deleted despite git missing!" + + def test_both_present_runs_rm_and_clone(self, tmp_path: Path): + """Bug 3: When both present, rm -rf runs before clone.""" + llama_dir = tmp_path / "llama.cpp" + llama_dir.mkdir() + marker = llama_dir / "marker.txt" + marker.write_text("existing") + + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir() + (mock_bin / "cmake").write_text("#!/bin/bash\nexit 0\n") + (mock_bin / "cmake").chmod(0o755) + (mock_bin / "git").write_text("#!/bin/bash\nexit 0\n") + (mock_bin / "git").chmod(0o755) + + script = textwrap.dedent(f"""\ + export PATH="{mock_bin}:$PATH" + export LLAMA_CPP_DIR="{llama_dir}" + if ! command -v cmake &>/dev/null; then + echo "cmake_missing" + elif ! command -v git &>/dev/null; then + echo "git_missing" + else + rm -rf "$LLAMA_CPP_DIR" + echo "would_clone" + fi + """) + output = run_bash(script) + assert "would_clone" in output + assert not marker.exists(), "Install dir should have been deleted" + + def test_clone_uses_pinned_tag(self, tmp_path: Path): + """Bug 2: git clone should use --branch with the resolved tag.""" + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir() + log_file = tmp_path / "git_calls.log" + (mock_bin / "git").write_text(f'#!/bin/bash\necho "$*" >> {log_file}\nexit 0\n') + (mock_bin / "git").chmod(0o755) + + script = textwrap.dedent(f"""\ + export PATH="{mock_bin}:$PATH" + git clone --depth 1 --branch "b8508" https://github.com/ggml-org/llama.cpp.git /tmp/llama_test + """) + run_bash(script) + log = log_file.read_text() + assert "--branch b8508" in log, f"Expected --branch b8508 in: {log}" + + def test_fetch_checkout_b_pattern(self, tmp_path: Path): + """Bug 1: Re-run should use fetch + checkout -B, not pull + checkout FETCH_HEAD.""" + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir() + log_file = tmp_path / "git_calls.log" + (mock_bin / "git").write_text(f'#!/bin/bash\necho "$*" >> {log_file}\nexit 0\n') + (mock_bin / "git").chmod(0o755) + + llama_dir = tmp_path / "llama.cpp" + llama_dir.mkdir() + (llama_dir / ".git").mkdir() + + script = textwrap.dedent(f"""\ + export PATH="{mock_bin}:$PATH" + LlamaCppDir="{llama_dir}" + ResolvedLlamaTag="b8508" + if [ -d "$LlamaCppDir/.git" ]; then + git -C "$LlamaCppDir" fetch --depth 1 origin "$ResolvedLlamaTag" + if [ $? -ne 0 ]; then + echo "WARN: fetch failed" + else + git -C "$LlamaCppDir" checkout -B unsloth-llama-build FETCH_HEAD + fi + fi + """) + run_bash(script) + log = log_file.read_text() + assert "fetch --depth 1 origin b8508" in log + assert "checkout -B unsloth-llama-build FETCH_HEAD" in log + assert "pull" not in log, "Should use fetch, not pull" + + def test_fetch_failure_warns_not_aborts(self, tmp_path: Path): + """Bug 1: fetch failure should warn and continue, not set BuildOk=false.""" + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir() + (mock_bin / "git").write_text( + '#!/bin/bash\nif echo "$*" | grep -q fetch; then exit 1; fi\nexit 0\n' + ) + (mock_bin / "git").chmod(0o755) + + llama_dir = tmp_path / "llama.cpp" + llama_dir.mkdir() + (llama_dir / ".git").mkdir() + + script = textwrap.dedent(f"""\ + export PATH="{mock_bin}:$PATH" + LlamaCppDir="{llama_dir}" + ResolvedLlamaTag="b8508" + BuildOk=true + if [ -d "$LlamaCppDir/.git" ]; then + git -C "$LlamaCppDir" fetch --depth 1 origin "$ResolvedLlamaTag" + if [ $? -ne 0 ]; then + echo "WARN: fetch failed -- using existing source" + else + git -C "$LlamaCppDir" checkout -B unsloth-llama-build FETCH_HEAD + fi + fi + echo "BuildOk=$BuildOk" + """) + output = run_bash(script) + assert "WARN: fetch failed" in output + assert "BuildOk=true" in output + + +# ========================================================================= +# TEST GROUP D: "latest" tag resolution (bash subprocess) +# ========================================================================= +class TestLatestTagResolution: + """Test the fallback chain: Unsloth API -> ggml-org API -> raw.""" + + RESOLVE_TEMPLATE = textwrap.dedent("""\ + export PATH="{mock_bin}:$PATH" + _REQUESTED_LLAMA_TAG="{requested_tag}" + _RESOLVED_LLAMA_TAG="" + _RESOLVE_UPSTREAM_STATUS=1 + _HELPER_RELEASE_REPO="unslothai/llama.cpp" + if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then + if [ "$_REQUESTED_LLAMA_TAG" = "latest" ]; then + _RESOLVED_LLAMA_TAG="$(curl -fsSL "https://api.github.com/repos/${{_HELPER_RELEASE_REPO}}/releases/latest" 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" + if [ -z "$_RESOLVED_LLAMA_TAG" ]; then + _RESOLVED_LLAMA_TAG="$(curl -fsSL https://api.github.com/repos/ggml-org/llama.cpp/releases/latest 2>/dev/null | python -c "import sys,json; print(json.load(sys.stdin)['tag_name'])" 2>/dev/null)" || _RESOLVED_LLAMA_TAG="" + fi + fi + if [ -z "$_RESOLVED_LLAMA_TAG" ]; then + _RESOLVED_LLAMA_TAG="$_REQUESTED_LLAMA_TAG" + fi + fi + echo "$_RESOLVED_LLAMA_TAG" + """) + + @staticmethod + def _make_curl_mock( + mock_bin: Path, unsloth_response: str | None, ggml_response: str | None + ): + """Create a curl mock that returns different responses per repo.""" + lines = ["#!/bin/bash"] + if unsloth_response is not None: + lines.append( + f'if echo "$*" | grep -q "unslothai/llama.cpp"; then echo \'{unsloth_response}\'; exit 0; fi' + ) + else: + lines.append( + 'if echo "$*" | grep -q "unslothai/llama.cpp"; then exit 1; fi' + ) + if ggml_response is not None: + lines.append( + f'if echo "$*" | grep -q "ggml-org/llama.cpp"; then echo \'{ggml_response}\'; exit 0; fi' + ) + else: + lines.append('if echo "$*" | grep -q "ggml-org/llama.cpp"; then exit 1; fi') + lines.append("exit 1") + curl_path = mock_bin / "curl" + curl_path.write_text("\n".join(lines) + "\n") + curl_path.chmod(0o755) + + def _run_resolve( + self, + tmp_path: Path, + requested_tag: str, + unsloth_resp: str | None, + ggml_resp: str | None, + ) -> str: + mock_bin = tmp_path / "mock_bin" + mock_bin.mkdir(exist_ok = True) + self._make_curl_mock(mock_bin, unsloth_resp, ggml_resp) + script = self.RESOLVE_TEMPLATE.format( + mock_bin = mock_bin, requested_tag = requested_tag + ) + return run_bash(script) + + def test_unsloth_succeeds(self, tmp_path: Path): + output = self._run_resolve( + tmp_path, + "latest", + unsloth_resp = '{"tag_name":"b8508"}', + ggml_resp = '{"tag_name":"b9000"}', + ) + assert output == "b8508" + + def test_unsloth_fails_ggml_succeeds(self, tmp_path: Path): + output = self._run_resolve( + tmp_path, + "latest", + unsloth_resp = None, + ggml_resp = '{"tag_name":"b9000"}', + ) + assert output == "b9000" + + def test_both_fail_raw_fallback(self, tmp_path: Path): + output = self._run_resolve( + tmp_path, + "latest", + unsloth_resp = None, + ggml_resp = None, + ) + assert output == "latest" + + def test_concrete_tag_passes_through(self, tmp_path: Path): + output = self._run_resolve( + tmp_path, + "b7777", + unsloth_resp = '{"tag_name":"b8508"}', + ggml_resp = '{"tag_name":"b9000"}', + ) + assert output == "b7777" + + def test_unsloth_malformed_json_falls_through(self, tmp_path: Path): + output = self._run_resolve( + tmp_path, + "latest", + unsloth_resp = '{"bad_key":"no_tag"}', + ggml_resp = '{"tag_name":"b9001"}', + ) + assert output == "b9001" + + def test_both_malformed_json_raw_fallback(self, tmp_path: Path): + output = self._run_resolve( + tmp_path, + "latest", + unsloth_resp = '{"bad":"data"}', + ggml_resp = '{"also":"bad"}', + ) + assert output == "latest" + + def test_unsloth_empty_body_falls_through(self, tmp_path: Path): + output = self._run_resolve( + tmp_path, + "latest", + unsloth_resp = "", + ggml_resp = '{"tag_name":"b7000"}', + ) + assert output == "b7000" + + def test_unsloth_empty_tag_name_falls_through(self, tmp_path: Path): + output = self._run_resolve( + tmp_path, + "latest", + unsloth_resp = '{"tag_name":""}', + ggml_resp = '{"tag_name":"b6000"}', + ) + assert output == "b6000" + + def test_env_override_unsloth_llama_tag(self): + output = run_bash( + 'echo "${UNSLOTH_LLAMA_TAG:-latest}"', + env = {"UNSLOTH_LLAMA_TAG": "b1234"}, + ) + assert output == "b1234" + + def test_env_unset_defaults_to_latest(self): + env = os.environ.copy() + env.pop("UNSLOTH_LLAMA_TAG", None) + output = run_bash('echo "${UNSLOTH_LLAMA_TAG:-latest}"', env = env) + assert output == "latest" + + def test_env_empty_defaults_to_latest(self): + output = run_bash( + 'echo "${UNSLOTH_LLAMA_TAG:-latest}"', + env = {"UNSLOTH_LLAMA_TAG": ""}, + ) + assert output == "latest" + + +# ========================================================================= +# TEST GROUP E: Source file verification +# ========================================================================= +class TestSourceCodePatterns: + """Verify the actual source files contain the expected fix patterns.""" + + def test_setup_sh_no_rm_before_prereq_check(self): + """rm -rf must appear AFTER cmake/git checks, not before.""" + content = SETUP_SH.read_text() + # Find the source-build block + idx_else = content.find("# Check prerequisites") + assert idx_else != -1 + block = content[idx_else:] + # rm -rf should appear after the cmake/git checks + idx_cmake = block.find("command -v cmake") + idx_git = block.find("command -v git") + idx_rm = block.find("rm -rf") + assert idx_rm > idx_cmake, "rm -rf should come after cmake check" + assert idx_rm > idx_git, "rm -rf should come after git check" + + def test_setup_sh_clone_uses_branch_tag(self): + """git clone in source-build should use --branch via _CLONE_BRANCH_ARGS.""" + content = SETUP_SH.read_text() + # The clone line should use _CLONE_BRANCH_ARGS (which conditionally includes --branch) + assert ( + "_CLONE_BRANCH_ARGS" in content + ), "Clone should use _CLONE_BRANCH_ARGS array" + assert ( + '--branch "$_RESOLVED_LLAMA_TAG"' in content + ), "_CLONE_BRANCH_ARGS should be set to --branch $_RESOLVED_LLAMA_TAG" + # Verify the guard: --branch is only used when tag is not "latest" + assert ( + '_RESOLVED_LLAMA_TAG" != "latest"' in content + ), "Should guard against literal 'latest' tag" + + def test_setup_sh_latest_resolution_queries_unsloth_first(self): + """The Unsloth repo should be queried before ggml-org.""" + content = SETUP_SH.read_text() + idx_unsloth = content.find("_HELPER_RELEASE_REPO}/releases/latest") + idx_ggml = content.find("ggml-org/llama.cpp/releases/latest") + assert idx_unsloth != -1, "Unsloth API query not found" + assert idx_ggml != -1, "ggml-org API query not found" + assert idx_unsloth < idx_ggml, "Unsloth should be queried before ggml-org" + + def test_setup_ps1_uses_checkout_b(self): + """PS1 should use checkout -B, not checkout --force FETCH_HEAD.""" + content = SETUP_PS1.read_text() + assert "checkout -B unsloth-llama-build" in content + assert "checkout --force FETCH_HEAD" not in content + + def test_setup_ps1_clone_uses_branch_tag(self): + """PS1 clone should use --branch with the resolved tag.""" + content = SETUP_PS1.read_text() + assert "--branch" in content and "$ResolvedLlamaTag" in content + # The old commented-out line should be gone + assert "# git clone --depth 1 --branch" not in content + + def test_setup_ps1_no_git_pull(self): + """PS1 should use fetch, not pull (which fails in detached HEAD).""" + content = SETUP_PS1.read_text() + # In the source-build section, there should be no "git pull" + # (git pull is only valid on a branch) + lines = content.splitlines() + for i, line in enumerate(lines): + stripped = line.strip() + if "git pull" in stripped and not stripped.startswith("#"): + # Check context -- should not be in the llama.cpp build section + # Allow git pull in other contexts + context = "\n".join(lines[max(0, i - 5) : i + 5]) + if "LlamaCppDir" in context: + pytest.fail( + f"Found 'git pull' in llama.cpp build section at line {i+1}" + ) + + def test_setup_ps1_latest_resolution_queries_unsloth_first(self): + """PS1 should query Unsloth repo before ggml-org.""" + content = SETUP_PS1.read_text() + idx_unsloth = content.find("$HelperReleaseRepo/releases/latest") + idx_ggml = content.find("ggml-org/llama.cpp/releases/latest") + assert idx_unsloth != -1, "Unsloth API query not found in PS1" + assert idx_ggml != -1, "ggml-org API query not found in PS1" + assert idx_unsloth < idx_ggml, "Unsloth should be queried before ggml-org" + + def test_binary_env_linux_has_binary_parent(self): + """The Linux branch of binary_env should include binary_path.parent.""" + content = MODULE_PATH.read_text() + # Find the binary_env function + in_func = False + in_linux = False + found = False + for line in content.splitlines(): + if "def binary_env(" in line: + in_func = True + elif in_func and line and not line[0].isspace() and "def " in line: + break + if in_func and "host.is_linux" in line: + in_linux = True + if in_linux and "binary_path.parent" in line: + found = True + break + assert found, "binary_path.parent not found in Linux branch of binary_env" diff --git a/tests/studio/install/test_selection_logic.py b/tests/studio/install/test_selection_logic.py new file mode 100644 index 0000000000..906c978b0d --- /dev/null +++ b/tests/studio/install/test_selection_logic.py @@ -0,0 +1,903 @@ +"""Tests for binary selection logic in install_llama_prebuilt.py. + +Covers: normalize_compute_cap, normalize_compute_caps, parse_cuda_visible_devices, +supports_explicit_visible_device_matching, select_visible_gpu_rows, +compatible_linux_runtime_lines, pick_windows_cuda_runtime, +compatible_windows_runtime_lines, runtime_line_from_cuda_version, +apply_approved_hashes, linux_cuda_choice_from_release, windows_cuda_attempts, +resolve_upstream_asset_choice. + +No GPU, no network, no torch required -- all I/O is monkeypatched. +""" + +import importlib.util +import sys +from pathlib import Path + +import pytest + + +PACKAGE_ROOT = Path(__file__).resolve().parents[3] +MODULE_PATH = PACKAGE_ROOT / "studio" / "install_llama_prebuilt.py" +SPEC = importlib.util.spec_from_file_location( + "studio_install_llama_prebuilt", MODULE_PATH +) +assert SPEC is not None and SPEC.loader is not None +INSTALL_LLAMA_PREBUILT = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = INSTALL_LLAMA_PREBUILT +SPEC.loader.exec_module(INSTALL_LLAMA_PREBUILT) + +HostInfo = INSTALL_LLAMA_PREBUILT.HostInfo +AssetChoice = INSTALL_LLAMA_PREBUILT.AssetChoice +PublishedLlamaArtifact = INSTALL_LLAMA_PREBUILT.PublishedLlamaArtifact +PublishedReleaseBundle = INSTALL_LLAMA_PREBUILT.PublishedReleaseBundle +ApprovedArtifactHash = INSTALL_LLAMA_PREBUILT.ApprovedArtifactHash +ApprovedReleaseChecksums = INSTALL_LLAMA_PREBUILT.ApprovedReleaseChecksums +PrebuiltFallback = INSTALL_LLAMA_PREBUILT.PrebuiltFallback +LinuxCudaSelection = INSTALL_LLAMA_PREBUILT.LinuxCudaSelection +UPSTREAM_REPO = INSTALL_LLAMA_PREBUILT.UPSTREAM_REPO + +normalize_compute_cap = INSTALL_LLAMA_PREBUILT.normalize_compute_cap +normalize_compute_caps = INSTALL_LLAMA_PREBUILT.normalize_compute_caps +parse_cuda_visible_devices = INSTALL_LLAMA_PREBUILT.parse_cuda_visible_devices +supports_explicit_visible_device_matching = ( + INSTALL_LLAMA_PREBUILT.supports_explicit_visible_device_matching +) +select_visible_gpu_rows = INSTALL_LLAMA_PREBUILT.select_visible_gpu_rows +compatible_linux_runtime_lines = INSTALL_LLAMA_PREBUILT.compatible_linux_runtime_lines +pick_windows_cuda_runtime = INSTALL_LLAMA_PREBUILT.pick_windows_cuda_runtime +compatible_windows_runtime_lines = ( + INSTALL_LLAMA_PREBUILT.compatible_windows_runtime_lines +) +runtime_line_from_cuda_version = INSTALL_LLAMA_PREBUILT.runtime_line_from_cuda_version +apply_approved_hashes = INSTALL_LLAMA_PREBUILT.apply_approved_hashes +linux_cuda_choice_from_release = INSTALL_LLAMA_PREBUILT.linux_cuda_choice_from_release +windows_cuda_attempts = INSTALL_LLAMA_PREBUILT.windows_cuda_attempts +resolve_upstream_asset_choice = INSTALL_LLAMA_PREBUILT.resolve_upstream_asset_choice + + +# --------------------------------------------------------------------------- +# Helper factories +# --------------------------------------------------------------------------- + + +def make_host(**overrides): + system = overrides.pop("system", "Linux") + machine = overrides.pop("machine", "x86_64") + defaults = dict( + system = system, + machine = machine, + is_linux = system == "Linux", + is_windows = system == "Windows", + is_macos = system == "Darwin", + is_x86_64 = machine.lower() in {"x86_64", "amd64"}, + is_arm64 = machine.lower() in {"arm64", "aarch64"}, + nvidia_smi = "/usr/bin/nvidia-smi", + driver_cuda_version = (12, 8), + compute_caps = ["86"], + visible_cuda_devices = None, + has_physical_nvidia = True, + has_usable_nvidia = True, + ) + defaults.update(overrides) + return HostInfo(**defaults) + + +def make_artifact(asset_name, **overrides): + defaults = dict( + asset_name = asset_name, + install_kind = "linux-cuda", + runtime_line = "cuda12", + coverage_class = "targeted", + supported_sms = ["75", "80", "86", "89", "90"], + min_sm = 75, + max_sm = 90, + bundle_profile = "cuda12-newer", + rank = 100, + ) + defaults.update(overrides) + return PublishedLlamaArtifact(**defaults) + + +def make_release(artifacts, **overrides): + defaults = dict( + repo = "unslothai/llama.cpp", + release_tag = "v1.0", + upstream_tag = "b8508", + assets = {a.asset_name: f"https://example.com/{a.asset_name}" for a in artifacts}, + manifest_asset_name = "llama-prebuilt-manifest.json", + artifacts = artifacts, + selection_log = [], + ) + defaults.update(overrides) + return PublishedReleaseBundle(**defaults) + + +def make_checksums(asset_names): + return ApprovedReleaseChecksums( + repo = "unslothai/llama.cpp", + release_tag = "v1.0", + upstream_tag = "b8508", + source_commit = None, + artifacts = { + name: ApprovedArtifactHash( + asset_name = name, + sha256 = "a" * 64, + repo = "unslothai/llama.cpp", + kind = "prebuilt", + ) + for name in asset_names + }, + ) + + +def mock_linux_runtime(monkeypatch, lines): + dirs = {line: ["/usr/lib/stub"] for line in lines} + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "detected_linux_runtime_lines", + lambda: (list(lines), dict(dirs)), + ) + + +def mock_windows_runtime(monkeypatch, lines): + dirs = {line: ["C:\\Windows\\System32"] for line in lines} + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "detected_windows_runtime_lines", + lambda: (list(lines), dict(dirs)), + ) + + +# =========================================================================== +# A. normalize_compute_cap +# =========================================================================== + + +class TestNormalizeComputeCap: + def test_dotted_86(self): + assert normalize_compute_cap("8.6") == "86" + + def test_dotted_leading_zero(self): + assert normalize_compute_cap("07.05") == "75" + + def test_already_normalized(self): + assert normalize_compute_cap("75") == "75" + + def test_int_input(self): + assert normalize_compute_cap(86) == "86" + + def test_empty_string(self): + assert normalize_compute_cap("") is None + + def test_whitespace(self): + assert normalize_compute_cap(" ") is None + + def test_non_numeric(self): + assert normalize_compute_cap("x.y") is None + + def test_triple_part(self): + assert normalize_compute_cap("8.6.0") is None + + def test_zero_minor(self): + assert normalize_compute_cap("9.0") == "90" + + +# =========================================================================== +# B. normalize_compute_caps +# =========================================================================== + + +class TestNormalizeComputeCaps: + def test_deduplication(self): + assert normalize_compute_caps(["8.6", "86", "8.6"]) == ["86"] + + def test_numeric_sort(self): + assert normalize_compute_caps(["9.0", "7.5", "8.6"]) == ["75", "86", "90"] + + def test_drops_invalid(self): + assert normalize_compute_caps(["8.6", "bad", "", "7.5"]) == ["75", "86"] + + def test_empty_input(self): + assert normalize_compute_caps([]) == [] + + +# =========================================================================== +# C. parse_cuda_visible_devices +# =========================================================================== + + +class TestParseCudaVisibleDevices: + def test_none(self): + assert parse_cuda_visible_devices(None) is None + + def test_empty(self): + assert parse_cuda_visible_devices("") == [] + + def test_minus_one(self): + assert parse_cuda_visible_devices("-1") == [] + + def test_single(self): + assert parse_cuda_visible_devices("0") == ["0"] + + def test_multi(self): + assert parse_cuda_visible_devices("0,1,2") == ["0", "1", "2"] + + def test_whitespace_stripped(self): + assert parse_cuda_visible_devices(" 0 , 1 ") == ["0", "1"] + + +# =========================================================================== +# D. supports_explicit_visible_device_matching +# =========================================================================== + + +class TestSupportsExplicitVisibleDeviceMatching: + def test_all_digits(self): + assert supports_explicit_visible_device_matching(["0", "1", "2"]) is True + + def test_gpu_prefix(self): + assert supports_explicit_visible_device_matching(["GPU-abc123"]) is True + + def test_none(self): + assert supports_explicit_visible_device_matching(None) is False + + def test_empty(self): + assert supports_explicit_visible_device_matching([]) is False + + def test_mixed_invalid(self): + assert supports_explicit_visible_device_matching(["0", "MIG-device"]) is False + + +# =========================================================================== +# E. select_visible_gpu_rows +# =========================================================================== + + +class TestSelectVisibleGpuRows: + ROWS = [ + ("0", "GPU-aaa", "8.6"), + ("1", "GPU-bbb", "7.5"), + ("2", "GPU-ccc", "8.9"), + ] + + def test_none_returns_all(self): + assert select_visible_gpu_rows(self.ROWS, None) == list(self.ROWS) + + def test_empty_returns_empty(self): + assert select_visible_gpu_rows(self.ROWS, []) == [] + + def test_filter_by_index(self): + result = select_visible_gpu_rows(self.ROWS, ["0", "2"]) + assert result == [("0", "GPU-aaa", "8.6"), ("2", "GPU-ccc", "8.9")] + + def test_filter_by_uuid_case_insensitive(self): + result = select_visible_gpu_rows(self.ROWS, ["gpu-bbb"]) + assert result == [("1", "GPU-bbb", "7.5")] + + def test_dedup_same_device(self): + result = select_visible_gpu_rows(self.ROWS, ["0", "0"]) + assert result == [("0", "GPU-aaa", "8.6")] + + def test_missing_token(self): + result = select_visible_gpu_rows(self.ROWS, ["99"]) + assert result == [] + + +# =========================================================================== +# F. compatible_linux_runtime_lines +# =========================================================================== + + +class TestCompatibleLinuxRuntimeLines: + def test_no_driver(self): + host = make_host(driver_cuda_version = None) + assert compatible_linux_runtime_lines(host) == [] + + def test_driver_11_8(self): + host = make_host(driver_cuda_version = (11, 8)) + assert compatible_linux_runtime_lines(host) == [] + + def test_driver_12_4(self): + host = make_host(driver_cuda_version = (12, 4)) + assert compatible_linux_runtime_lines(host) == ["cuda12"] + + def test_driver_13_0(self): + host = make_host(driver_cuda_version = (13, 0)) + assert compatible_linux_runtime_lines(host) == ["cuda13", "cuda12"] + + +# =========================================================================== +# G. pick_windows_cuda_runtime + compatible_windows_runtime_lines +# =========================================================================== + + +class TestPickWindowsCudaRuntime: + def test_no_driver(self): + host = make_host(driver_cuda_version = None) + assert pick_windows_cuda_runtime(host) is None + + def test_below_threshold(self): + host = make_host(driver_cuda_version = (12, 3)) + assert pick_windows_cuda_runtime(host) is None + + def test_driver_12_4(self): + host = make_host(driver_cuda_version = (12, 4)) + assert pick_windows_cuda_runtime(host) == "12.4" + + def test_driver_13_1(self): + host = make_host(driver_cuda_version = (13, 1)) + assert pick_windows_cuda_runtime(host) == "13.1" + + +class TestCompatibleWindowsRuntimeLines: + def test_no_driver(self): + host = make_host(driver_cuda_version = None) + assert compatible_windows_runtime_lines(host) == [] + + def test_driver_12_4(self): + host = make_host(driver_cuda_version = (12, 4)) + assert compatible_windows_runtime_lines(host) == ["cuda12"] + + def test_driver_13_1(self): + host = make_host(driver_cuda_version = (13, 1)) + assert compatible_windows_runtime_lines(host) == ["cuda13", "cuda12"] + + +# =========================================================================== +# H. runtime_line_from_cuda_version +# =========================================================================== + + +class TestRuntimeLineFromCudaVersion: + def test_cuda_12(self): + assert runtime_line_from_cuda_version("12.6") == "cuda12" + + def test_cuda_13(self): + assert runtime_line_from_cuda_version("13.0") == "cuda13" + + def test_cuda_11(self): + assert runtime_line_from_cuda_version("11.8") is None + + def test_none(self): + assert runtime_line_from_cuda_version(None) is None + + def test_empty(self): + assert runtime_line_from_cuda_version("") is None + + +# =========================================================================== +# I. apply_approved_hashes +# =========================================================================== + + +class TestApplyApprovedHashes: + def _choice(self, name): + return AssetChoice( + repo = "test", + tag = "v1", + name = name, + url = f"https://x/{name}", + source_label = "test", + ) + + def test_both_approved(self): + c1, c2 = self._choice("a.tar.gz"), self._choice("b.tar.gz") + checksums = make_checksums(["a.tar.gz", "b.tar.gz"]) + result = apply_approved_hashes([c1, c2], checksums) + assert len(result) == 2 + assert all(c.expected_sha256 == "a" * 64 for c in result) + + def test_one_approved(self): + c1, c2 = self._choice("a.tar.gz"), self._choice("missing.tar.gz") + checksums = make_checksums(["a.tar.gz"]) + result = apply_approved_hashes([c1, c2], checksums) + assert len(result) == 1 + assert result[0].name == "a.tar.gz" + + def test_none_approved(self): + c1 = self._choice("missing.tar.gz") + checksums = make_checksums(["other.tar.gz"]) + with pytest.raises(PrebuiltFallback, match = "approved checksum"): + apply_approved_hashes([c1], checksums) + + def test_empty_input(self): + checksums = make_checksums(["a.tar.gz"]) + with pytest.raises(PrebuiltFallback, match = "approved checksum"): + apply_approved_hashes([], checksums) + + +# =========================================================================== +# J. linux_cuda_choice_from_release -- core selection +# =========================================================================== + + +class TestLinuxCudaChoiceFromRelease: + # --- Runtime line resolution --- + + def test_no_runtime_lines_detected(self, monkeypatch): + mock_linux_runtime(monkeypatch, []) + host = make_host(driver_cuda_version = (12, 8)) + art = make_artifact("bundle-cuda12.tar.gz") + release = make_release([art]) + assert linux_cuda_choice_from_release(host, release) is None + + def test_detected_lines_incompatible_with_driver(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda13"]) + host = make_host(driver_cuda_version = (12, 4)) + art = make_artifact("bundle-cuda13.tar.gz", runtime_line = "cuda13") + release = make_release([art]) + assert linux_cuda_choice_from_release(host, release) is None + + def test_driver_13_only_cuda12_detected(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(driver_cuda_version = (13, 0)) + art = make_artifact("bundle-cuda12.tar.gz", runtime_line = "cuda12") + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is not None + assert result.primary.runtime_line == "cuda12" + + def test_preferred_runtime_line_reorders(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda13", "cuda12"]) + host = make_host(driver_cuda_version = (13, 0)) + art12 = make_artifact("bundle-cuda12.tar.gz", runtime_line = "cuda12") + art13 = make_artifact("bundle-cuda13.tar.gz", runtime_line = "cuda13") + release = make_release([art12, art13]) + result = linux_cuda_choice_from_release( + host, release, preferred_runtime_line = "cuda12" + ) + assert result is not None + assert result.primary.runtime_line == "cuda12" + + def test_preferred_runtime_line_unavailable(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(driver_cuda_version = (12, 8)) + art = make_artifact("bundle-cuda12.tar.gz", runtime_line = "cuda12") + release = make_release([art]) + result = linux_cuda_choice_from_release( + host, release, preferred_runtime_line = "cuda13" + ) + assert result is not None + assert result.primary.runtime_line == "cuda12" + log_entries = result.selection_log + assert any("unavailable_on_host" in entry for entry in log_entries) + + # --- SM matching --- + + def test_exact_sm_match(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + art = make_artifact( + "bundle.tar.gz", supported_sms = ["75", "86", "89"], min_sm = 75, max_sm = 89 + ) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is not None + assert result.primary.name == "bundle.tar.gz" + + def test_sm_not_in_supported_sms(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + art = make_artifact( + "bundle.tar.gz", supported_sms = ["75", "80", "89"], min_sm = 75, max_sm = 89 + ) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_sm_outside_min_range(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["50"]) + art = make_artifact( + "bundle.tar.gz", supported_sms = ["50", "75", "86"], min_sm = 75, max_sm = 90 + ) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_sm_outside_max_range(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["100"]) + art = make_artifact( + "bundle.tar.gz", supported_sms = ["100", "75", "86"], min_sm = 75, max_sm = 90 + ) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_very_old_sm(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["50"]) + art = make_artifact("bundle.tar.gz", min_sm = 75, max_sm = 90) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_very_new_sm(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["100"]) + art = make_artifact("bundle.tar.gz", min_sm = 75, max_sm = 90) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + # --- Unknown compute caps (empty list) --- + + def test_unknown_caps_only_portable(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = []) + targeted = make_artifact("targeted.tar.gz", coverage_class = "targeted") + portable = make_artifact("portable.tar.gz", coverage_class = "portable") + release = make_release([targeted, portable]) + result = linux_cuda_choice_from_release(host, release) + assert result is not None + assert result.primary.name == "portable.tar.gz" + + def test_unknown_caps_no_portable(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = []) + targeted = make_artifact("targeted.tar.gz", coverage_class = "targeted") + release = make_release([targeted]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + # --- Multi-GPU --- + + def test_multi_gpu_all_covered(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["75", "89"]) + art = make_artifact( + "bundle.tar.gz", + supported_sms = ["75", "80", "86", "89", "90"], + min_sm = 75, + max_sm = 90, + ) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is not None + + def test_multi_gpu_not_all_covered(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["50", "89"]) + art = make_artifact( + "bundle.tar.gz", supported_sms = ["75", "89"], min_sm = 75, max_sm = 89 + ) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + # --- Artifact selection priority --- + + def test_narrowest_sm_range_wins(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + wide = make_artifact( + "wide.tar.gz", + supported_sms = ["75", "86", "90"], + min_sm = 75, + max_sm = 90, + rank = 100, + ) + narrow = make_artifact( + "narrow.tar.gz", + supported_sms = ["80", "86", "89"], + min_sm = 80, + max_sm = 89, + rank = 100, + ) + release = make_release([wide, narrow]) + result = linux_cuda_choice_from_release(host, release) + assert result is not None + assert result.primary.name == "narrow.tar.gz" + + def test_range_tie_lower_rank_wins(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + high = make_artifact( + "high.tar.gz", + supported_sms = ["75", "86", "90"], + min_sm = 75, + max_sm = 90, + rank = 200, + ) + low = make_artifact( + "low.tar.gz", + supported_sms = ["75", "86", "90"], + min_sm = 75, + max_sm = 90, + rank = 50, + ) + release = make_release([high, low]) + result = linux_cuda_choice_from_release(host, release) + assert result is not None + assert result.primary.name == "low.tar.gz" + + def test_targeted_preferred_portable_fallback(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + targeted = make_artifact("targeted.tar.gz", coverage_class = "targeted", rank = 100) + portable = make_artifact("portable.tar.gz", coverage_class = "portable", rank = 100) + release = make_release([targeted, portable]) + result = linux_cuda_choice_from_release(host, release) + assert result is not None + assert result.primary.name == "targeted.tar.gz" + assert len(result.attempts) == 2 + assert result.attempts[1].name == "portable.tar.gz" + + # --- Edge cases --- + + def test_asset_missing_from_release_assets(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + art = make_artifact("bundle.tar.gz") + release = make_release([art], assets = {}) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_artifact_empty_supported_sms(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + art = make_artifact("bundle.tar.gz", supported_sms = []) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_artifact_missing_min_sm(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + art = make_artifact("bundle.tar.gz", min_sm = None, max_sm = 90) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_artifact_missing_max_sm(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + art = make_artifact("bundle.tar.gz", min_sm = 75, max_sm = None) + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_no_linux_cuda_artifacts(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + art = make_artifact("bundle.tar.gz", install_kind = "windows-cuda") + release = make_release([art]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + def test_empty_artifacts_list(self, monkeypatch): + mock_linux_runtime(monkeypatch, ["cuda12"]) + host = make_host(compute_caps = ["86"]) + release = make_release([]) + result = linux_cuda_choice_from_release(host, release) + assert result is None + + +# =========================================================================== +# K. windows_cuda_attempts +# =========================================================================== + + +class TestWindowsCudaAttempts: + TAG = "b8508" + + def _upstream(self, *runtime_versions): + assets = {} + for rv in runtime_versions: + name = f"llama-{self.TAG}-bin-win-cuda-{rv}-x64.zip" + assets[name] = f"https://example.com/{name}" + return assets + + def test_driver_12_4_no_dlls_fallback(self, monkeypatch): + mock_windows_runtime(monkeypatch, []) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (12, 4)) + assets = self._upstream("12.4") + result = windows_cuda_attempts(host, self.TAG, assets, None) + assert len(result) == 1 + assert result[0].runtime_line == "cuda12" + + def test_driver_13_1_both_dlls(self, monkeypatch): + mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"]) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 1)) + assets = self._upstream("13.1", "12.4") + result = windows_cuda_attempts(host, self.TAG, assets, None) + assert len(result) == 2 + assert result[0].runtime_line == "cuda13" + assert result[1].runtime_line == "cuda12" + + def test_preferred_reorders(self, monkeypatch): + mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"]) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 1)) + assets = self._upstream("13.1", "12.4") + result = windows_cuda_attempts(host, self.TAG, assets, "cuda12") + assert len(result) == 2 + assert result[0].runtime_line == "cuda12" + + def test_preferred_unavailable(self, monkeypatch): + mock_windows_runtime(monkeypatch, ["cuda12"]) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (12, 4)) + assets = self._upstream("12.4") + result = windows_cuda_attempts(host, self.TAG, assets, "cuda13") + assert len(result) == 1 + assert result[0].runtime_line == "cuda12" + + def test_detected_incompatible_with_driver(self, monkeypatch): + mock_windows_runtime(monkeypatch, ["cuda13"]) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (12, 4)) + assets = self._upstream("12.4") + result = windows_cuda_attempts(host, self.TAG, assets, None) + assert len(result) == 1 + assert result[0].runtime_line == "cuda12" + + def test_driver_too_old(self, monkeypatch): + mock_windows_runtime(monkeypatch, []) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (11, 8)) + assets = self._upstream("12.4") + result = windows_cuda_attempts(host, self.TAG, assets, None) + assert result == [] + + def test_asset_missing_from_upstream(self, monkeypatch): + mock_windows_runtime(monkeypatch, ["cuda12"]) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (12, 4)) + result = windows_cuda_attempts(host, self.TAG, {}, None) + assert result == [] + + def test_both_assets_present(self, monkeypatch): + mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"]) + host = make_host(system = "Windows", machine = "AMD64", driver_cuda_version = (13, 1)) + assets = self._upstream("13.1", "12.4") + result = windows_cuda_attempts(host, self.TAG, assets, None) + assert len(result) == 2 + + +# =========================================================================== +# L. resolve_upstream_asset_choice -- platform routing +# =========================================================================== + + +class TestResolveUpstreamAssetChoice: + TAG = "b8508" + + def _mock_github_assets(self, monkeypatch, assets): + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "github_release_assets", + lambda repo, tag: assets, + ) + + def test_linux_x86_64_cpu(self, monkeypatch): + name = f"llama-{self.TAG}-bin-ubuntu-x64.tar.gz" + self._mock_github_assets(monkeypatch, {name: f"https://x/{name}"}) + host = make_host( + has_usable_nvidia = False, nvidia_smi = None, has_physical_nvidia = False + ) + result = resolve_upstream_asset_choice(host, self.TAG) + assert result.install_kind == "linux-cpu" + assert result.name == name + + def test_linux_cpu_missing(self, monkeypatch): + self._mock_github_assets(monkeypatch, {}) + host = make_host( + has_usable_nvidia = False, nvidia_smi = None, has_physical_nvidia = False + ) + with pytest.raises(PrebuiltFallback, match = "Linux CPU"): + resolve_upstream_asset_choice(host, self.TAG) + + def test_windows_x86_64_cpu(self, monkeypatch): + name = f"llama-{self.TAG}-bin-win-cpu-x64.zip" + self._mock_github_assets(monkeypatch, {name: f"https://x/{name}"}) + host = make_host( + system = "Windows", + machine = "AMD64", + has_usable_nvidia = False, + nvidia_smi = None, + has_physical_nvidia = False, + ) + result = resolve_upstream_asset_choice(host, self.TAG) + assert result.install_kind == "windows-cpu" + assert result.name == name + + def test_windows_cpu_missing(self, monkeypatch): + self._mock_github_assets(monkeypatch, {}) + host = make_host( + system = "Windows", + machine = "AMD64", + has_usable_nvidia = False, + nvidia_smi = None, + has_physical_nvidia = False, + ) + with pytest.raises(PrebuiltFallback, match = "Windows CPU"): + resolve_upstream_asset_choice(host, self.TAG) + + def test_macos_arm64(self, monkeypatch): + name = f"llama-{self.TAG}-bin-macos-arm64.tar.gz" + self._mock_github_assets(monkeypatch, {name: f"https://x/{name}"}) + host = make_host( + system = "Darwin", + machine = "arm64", + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + result = resolve_upstream_asset_choice(host, self.TAG) + assert result.install_kind == "macos-arm64" + assert result.name == name + + def test_macos_arm64_missing(self, monkeypatch): + self._mock_github_assets(monkeypatch, {}) + host = make_host( + system = "Darwin", + machine = "arm64", + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + with pytest.raises(PrebuiltFallback, match = "macOS arm64"): + resolve_upstream_asset_choice(host, self.TAG) + + def test_macos_x86_64(self, monkeypatch): + name = f"llama-{self.TAG}-bin-macos-x64.tar.gz" + self._mock_github_assets(monkeypatch, {name: f"https://x/{name}"}) + host = make_host( + system = "Darwin", + machine = "x86_64", + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + result = resolve_upstream_asset_choice(host, self.TAG) + assert result.install_kind == "macos-x64" + assert result.name == name + + def test_linux_aarch64(self, monkeypatch): + self._mock_github_assets(monkeypatch, {}) + host = make_host( + system = "Linux", + machine = "aarch64", + nvidia_smi = None, + driver_cuda_version = None, + compute_caps = [], + has_physical_nvidia = False, + has_usable_nvidia = False, + ) + with pytest.raises( + PrebuiltFallback, match = "no prebuilt policy exists for Linux aarch64" + ): + resolve_upstream_asset_choice(host, self.TAG) + + def test_windows_usable_nvidia_delegates(self, monkeypatch): + cuda_name = f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip" + self._mock_github_assets(monkeypatch, {cuda_name: f"https://x/{cuda_name}"}) + mock_windows_runtime(monkeypatch, ["cuda12"]) + monkeypatch.setattr( + INSTALL_LLAMA_PREBUILT, + "resolve_windows_cuda_choices", + lambda host, tag, assets: [ + AssetChoice( + repo = UPSTREAM_REPO, + tag = tag, + name = cuda_name, + url = f"https://x/{cuda_name}", + source_label = "upstream", + install_kind = "windows-cuda", + runtime_line = "cuda12", + ) + ], + ) + host = make_host( + system = "Windows", + machine = "AMD64", + driver_cuda_version = (12, 4), + has_usable_nvidia = True, + ) + result = resolve_upstream_asset_choice(host, self.TAG) + assert result.install_kind == "windows-cuda" + assert result.name == cuda_name From d87c21aebf527c57ca14a6f1ab763ce0ec1ce543 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 06:14:33 -0700 Subject: [PATCH 07/94] fix(studio): add -ngl -1 when model fits on GPU to enable GPU offloading (#4588) When _select_gpus determines that a GGUF model fits on the selected GPU(s), the code sets CUDA_VISIBLE_DEVICES but never passes -ngl (number of GPU layers) to llama-server. Without -ngl or --fit, llama-server defaults to 0 GPU layers and runs entirely on CPU. This adds -ngl -1 (offload all layers) in the elif branch where gpu_indices is set and use_fit is False, so models that fit in VRAM actually use the GPU for inference. Co-authored-by: Daniel Han --- studio/backend/core/inference/llama_cpp.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 7b1db8fd04..81a087341a 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -857,6 +857,9 @@ class LlamaCppBackend: if use_fit: cmd.extend(["--fit", "on"]) + elif gpu_indices is not None: + # Model fits on selected GPU(s) -- offload all layers + cmd.extend(["-ngl", "-1"]) if n_threads is not None: cmd.extend(["--threads", str(n_threads)]) From ae2b1b97ba24b96b82ebab8a55facc5e27645ca2 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 06:24:40 -0700 Subject: [PATCH 08/94] fix(studio): add pip-installed nvidia CUDA libs to LD_LIBRARY_PATH for llama-server (#4590) The prebuilt llama.cpp binary (cuda13-newer) links against libcudart.so.13 and libcublas.so.13. When torch is installed via pip, these libraries live in the venv's site-packages under nvidia/cu13/lib/, not in /usr/local/cuda/. The existing LD_LIBRARY_PATH logic only searched /usr/local/cuda* paths (which have CUDA 12.x), so the CUDA backend failed to load silently and llama-server fell back to CPU -- even with -ngl -1. This adds a glob scan of the venv's nvidia package directories (cu*, cudnn, nvjitlink) to LD_LIBRARY_PATH before launching llama-server, matching where pip puts the CUDA runtime. Tested on Colab with RTX PRO 6000 Blackwell (CUDA 13.0, pip torch): before -- 3 MiB GPU, 0% util, CPU inference after -- 13317 MiB GPU, 77% util, full GPU inference Co-authored-by: Daniel Han --- studio/backend/core/inference/llama_cpp.py | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 81a087341a..1d5643ac09 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -969,6 +969,46 @@ class LlamaCppBackend: lib_dirs = [binary_dir] _arch = platform.machine() # x86_64, aarch64, etc. + + # Pip-installed nvidia CUDA runtime libs (e.g. torch's + # bundled cuda-bindings). The prebuilt llama.cpp binary + # links against libcudart.so.13 / libcublas.so.13 which + # live here, not in /usr/local/cuda. + import glob as _glob + + for _nv_pattern in [ + os.path.join( + sys.prefix, + "lib", + "python*", + "site-packages", + "nvidia", + "cu*", + "lib", + ), + os.path.join( + sys.prefix, + "lib", + "python*", + "site-packages", + "nvidia", + "cudnn", + "lib", + ), + os.path.join( + sys.prefix, + "lib", + "python*", + "site-packages", + "nvidia", + "nvjitlink", + "lib", + ), + ]: + for _nv_dir in _glob.glob(_nv_pattern): + if os.path.isdir(_nv_dir): + lib_dirs.append(_nv_dir) + for cuda_lib in [ "/usr/local/cuda/lib64", f"/usr/local/cuda/targets/{_arch}-linux/lib", From d56b115bb4f27e712f1d78b662962b251fc73c62 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 25 Mar 2026 13:24:29 +0000 Subject: [PATCH 09/94] feat: multi-source model discovery (HF default, legacy cache, LM Studio) --- studio/backend/models/models.py | 6 +- studio/backend/routes/models.py | 230 +++++++++++++----- studio/backend/utils/paths/__init__.py | 4 + studio/backend/utils/paths/storage_roots.py | 50 +++- .../studio/sections/model-section.tsx | 6 +- .../src/features/training/api/models-api.ts | 3 +- 6 files changed, 223 insertions(+), 76 deletions(-) diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index daa8eec907..046e36137d 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -165,7 +165,7 @@ class LocalModelInfo(BaseModel): id: str = Field(..., description = "Identifier to use for loading/training") display_name: str = Field(..., description = "Display label") path: str = Field(..., description = "Local path where model data was discovered") - source: Literal["models_dir", "hf_cache"] = Field( + source: Literal["models_dir", "hf_cache", "lmstudio"] = Field( ..., description = "Discovery source", ) @@ -189,6 +189,10 @@ class LocalModelListResponse(BaseModel): None, description = "HF cache root that was scanned", ) + lmstudio_dirs: List[str] = Field( + default_factory = list, + description = "LM Studio model directories that were scanned", + ) models: List[LocalModelInfo] = Field( default_factory = list, description = "Discovered local/cached models", diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index e705762447..63c9304a64 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -210,6 +210,76 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]: return found +def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: + """Scan an LM Studio models directory for model files. + + LM Studio uses a ``publisher/model-name`` folder structure containing + GGUF files, or standalone GGUF files at the top level. + """ + if not lm_dir.exists() or not lm_dir.is_dir(): + return [] + + found: List[LocalModelInfo] = [] + for child in lm_dir.iterdir(): + if not child.is_dir(): + if child.suffix == ".gguf" and child.is_file(): + try: + updated_at = child.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(child), + display_name = child.stem, + path = str(child), + source = "lmstudio", + updated_at = updated_at, + ), + ) + continue + + # child is a publisher directory — scan its sub-directories + for model_dir in child.iterdir(): + if model_dir.is_dir(): + has_model = ( + any(model_dir.glob("*.gguf")) + or (model_dir / "config.json").exists() + or any(model_dir.glob("*.safetensors")) + ) + if not has_model: + continue + model_id = f"{child.name}/{model_dir.name}" + try: + updated_at = model_dir.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = model_id, + model_id = model_id, + display_name = model_dir.name, + path = str(model_dir), + source = "lmstudio", + updated_at = updated_at, + ), + ) + elif model_dir.suffix == ".gguf" and model_dir.is_file(): + try: + updated_at = model_dir.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(model_dir), + display_name = model_dir.stem, + path = str(model_dir), + source = "lmstudio", + updated_at = updated_at, + ), + ) + return found + + @router.get("/local", response_model = LocalModelListResponse) async def list_local_models( models_dir: str = Query( @@ -218,13 +288,24 @@ async def list_local_models( current_subject: str = Depends(get_current_subject), ): """ - List local model candidates from custom models dir and HF cache. + List local model candidates from custom models dir, HF cache, + legacy Unsloth HF cache, and LM Studio directories. """ + from utils.paths import legacy_hf_cache_dir, lmstudio_model_dirs + + # Resolve all scan directories up front. + hf_cache_dir = _resolve_hf_cache_dir() + legacy_hf = legacy_hf_cache_dir() + lm_dirs = lmstudio_model_dirs() + # Validate models_dir against an allowlist of trusted directories. # Only the trusted Path objects are used for filesystem access -- the # user-supplied string is only used for matching, never for path construction. - hf_cache_dir = _resolve_hf_cache_dir() - allowed_roots = [Path("./models").resolve(), hf_cache_dir] + allowed_roots: list[Path] = [Path("./models").resolve(), hf_cache_dir] + if legacy_hf.is_dir(): + allowed_roots.append(legacy_hf) + for d in lm_dirs: + allowed_roots.append(d) try: from utils.paths import studio_root, outputs_root @@ -248,6 +329,14 @@ async def list_local_models( try: local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir) + # Scan legacy Unsloth HF cache for backward compatibility + if legacy_hf.is_dir() and legacy_hf.resolve() != hf_cache_dir.resolve(): + local_models += _scan_hf_cache(legacy_hf) + + # Scan LM Studio directories + for lm_dir in lm_dirs: + local_models += _scan_lmstudio_dir(lm_dir) + deduped: dict[str, LocalModelInfo] = {} for model in local_models: if model.id not in deduped: @@ -262,6 +351,7 @@ async def list_local_models( return LocalModelListResponse( models_dir = str(models_root), hf_cache_dir = str(hf_cache_dir), + lmstudio_dirs = [str(d) for d in lm_dirs], models = models, ) except Exception as e: @@ -850,42 +940,44 @@ def _get_repo_size_cached(repo_id: str) -> int: async def list_cached_gguf( current_subject: str = Depends(get_current_subject), ): - """List GGUF repos that have already been downloaded to the HF cache. - - Uses scan_cache_dir() for proper repo IDs, then deduplicates by - lowercased key (HF cache dirs are lowercased but the canonical repo - ID preserves casing). - """ + """List GGUF repos downloaded to HF cache and legacy Unsloth cache.""" try: from huggingface_hub import scan_cache_dir + from utils.paths import legacy_hf_cache_dir + + cache_scans = [scan_cache_dir()] + legacy_hf = legacy_hf_cache_dir() + if legacy_hf.is_dir(): + try: + cache_scans.append(scan_cache_dir(cache_dir = str(legacy_hf))) + except Exception: + pass - hf_cache = scan_cache_dir() seen_lower: dict[str, dict] = {} - for repo_info in hf_cache.repos: - if repo_info.repo_type != "model": - continue - repo_id = repo_info.repo_id - if not repo_id.upper().endswith("-GGUF"): - continue - # Check for actual .gguf files and sum sizes - total_size = 0 - has_gguf = False - for revision in repo_info.revisions: - for f in revision.files: - if f.file_name.endswith(".gguf"): - has_gguf = True - total_size += f.size_on_disk - if not has_gguf: - continue - # Deduplicate: keep the entry with the most data - key = repo_id.lower() - existing = seen_lower.get(key) - if existing is None or total_size > existing["size_bytes"]: - seen_lower[key] = { - "repo_id": repo_id, - "size_bytes": total_size, - "cache_path": str(repo_info.repo_path), - } + for hf_cache in cache_scans: + for repo_info in hf_cache.repos: + if repo_info.repo_type != "model": + continue + repo_id = repo_info.repo_id + if not repo_id.upper().endswith("-GGUF"): + continue + total_size = 0 + has_gguf = False + for revision in repo_info.revisions: + for f in revision.files: + if f.file_name.endswith(".gguf"): + has_gguf = True + total_size += f.size_on_disk + if not has_gguf: + continue + key = repo_id.lower() + existing = seen_lower.get(key) + if existing is None or total_size > existing["size_bytes"]: + seen_lower[key] = { + "repo_id": repo_id, + "size_bytes": total_size, + "cache_path": str(repo_info.repo_path), + } cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"]) return {"cached": cached} except Exception as e: @@ -897,44 +989,48 @@ async def list_cached_gguf( async def list_cached_models( current_subject: str = Depends(get_current_subject), ): - """List non-GGUF model repos that have been downloaded to the HF cache. - - Only includes repos that actually contain model weight files - (.safetensors, .bin), not repos with only config/metadata. - """ + """List non-GGUF model repos downloaded to HF cache and legacy Unsloth cache.""" _WEIGHT_EXTENSIONS = (".safetensors", ".bin") try: from huggingface_hub import scan_cache_dir + from utils.paths import legacy_hf_cache_dir + + cache_scans = [scan_cache_dir()] + legacy_hf = legacy_hf_cache_dir() + if legacy_hf.is_dir(): + try: + cache_scans.append(scan_cache_dir(cache_dir = str(legacy_hf))) + except Exception: + pass - hf_cache = scan_cache_dir() seen_lower: dict[str, dict] = {} - for repo_info in hf_cache.repos: - if repo_info.repo_type != "model": - continue - repo_id = repo_info.repo_id - if repo_id.upper().endswith("-GGUF"): - continue - total_size = sum( - f.size_on_disk for rev in repo_info.revisions for f in rev.files - ) - if total_size == 0: - continue - # Skip repos that only have config/metadata files (no weights) - has_weights = any( - f.file_name.endswith(_WEIGHT_EXTENSIONS) - for rev in repo_info.revisions - for f in rev.files - ) - if not has_weights: - continue - key = repo_id.lower() - existing = seen_lower.get(key) - if existing is None or total_size > existing["size_bytes"]: - seen_lower[key] = { - "repo_id": repo_id, - "size_bytes": total_size, - } + for hf_cache in cache_scans: + for repo_info in hf_cache.repos: + if repo_info.repo_type != "model": + continue + repo_id = repo_info.repo_id + if repo_id.upper().endswith("-GGUF"): + continue + total_size = sum( + f.size_on_disk for rev in repo_info.revisions for f in rev.files + ) + if total_size == 0: + continue + has_weights = any( + f.file_name.endswith(_WEIGHT_EXTENSIONS) + for rev in repo_info.revisions + for f in rev.files + ) + if not has_weights: + continue + key = repo_id.lower() + existing = seen_lower.get(key) + if existing is None or total_size > existing["size_bytes"]: + seen_lower[key] = { + "repo_id": repo_id, + "size_bytes": total_size, + } cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"]) return {"cached": cached} except Exception as e: diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 789052f372..aec6bb1292 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -23,6 +23,8 @@ from .storage_roots import ( unstructured_uploads_root, oxc_validator_tmp_root, tensorboard_root, + legacy_hf_cache_dir, + lmstudio_model_dirs, ensure_dir, ensure_studio_directories, resolve_under_root, @@ -53,6 +55,8 @@ __all__ = [ "unstructured_uploads_root", "oxc_validator_tmp_root", "tensorboard_root", + "legacy_hf_cache_dir", + "lmstudio_model_dirs", "ensure_dir", "ensure_studio_directories", "resolve_under_root", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 626e868275..08d3744e95 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -3,7 +3,9 @@ from __future__ import annotations +import json import os +import sys from pathlib import Path import tempfile @@ -82,19 +84,55 @@ def ensure_dir(path: Path) -> Path: return path +def legacy_hf_cache_dir() -> Path: + """Old Unsloth-specific HF hub cache, kept for backward-compat scanning.""" + return cache_root() / "huggingface" / "hub" + + +def lmstudio_model_dirs() -> list[Path]: + """Return LM Studio model directories that exist on disk.""" + dirs: list[Path] = [] + + # 1. Check LM Studio settings.json for custom downloads folder + settings_path = Path.home() / ".lmstudio" / "settings.json" + if settings_path.is_file(): + try: + with open(settings_path) as f: + settings = json.load(f) + downloads = settings.get("downloadsFolder", "") + if downloads: + p = Path(downloads).expanduser() + if p.is_dir(): + dirs.append(p) + except Exception: + pass + + # 2. Legacy LM Studio cache (Linux/macOS) + if sys.platform == "win32": + legacy = Path.home() / ".cache" / "lm-studio" / "models" + else: + legacy = Path.home() / ".cache" / "lm-studio" / "models" + if legacy.is_dir(): + dirs.append(legacy) + + return dirs + + def _setup_cache_env() -> None: - """Set cache environment variables for HuggingFace, uv, and vLLM. + """Set cache environment variables for uv and vLLM. + + HuggingFace cache variables (HF_HOME, HF_HUB_CACHE, HF_XET_CACHE) + are no longer overridden — HF uses its own defaults unless the user + has explicitly set them. The legacy Unsloth HF cache at + ``~/.unsloth/studio/cache/huggingface/hub`` is still scanned for + backward compatibility via :func:`legacy_hf_cache_dir`. Only sets variables that are not already set by the user, so - explicit overrides (e.g. HF_HOME=/data/hf) are respected. + explicit overrides are respected. Works on Linux, macOS, and Windows. """ root = cache_root() - hf_dir = root / "huggingface" defaults = { - "HF_HOME": str(hf_dir), - "HF_HUB_CACHE": str(hf_dir / "hub"), - "HF_XET_CACHE": str(hf_dir / "xet"), "UV_CACHE_DIR": str(root / "uv"), "VLLM_CACHE_ROOT": str(root / "vllm"), } diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx index e9bcde17b5..84df506769 100644 --- a/studio/frontend/src/features/studio/sections/model-section.tsx +++ b/studio/frontend/src/features/studio/sections/model-section.tsx @@ -334,7 +334,11 @@ export function ModelSection() { {(id: string) => { const model = localMetaById.get(id); const source = - model?.source === "hf_cache" ? "HF cache" : "Local dir"; + model?.source === "hf_cache" + ? "HF cache" + : model?.source === "lmstudio" + ? "LM Studio" + : "Local dir"; return ( diff --git a/studio/frontend/src/features/training/api/models-api.ts b/studio/frontend/src/features/training/api/models-api.ts index 84051e3e1d..f2b1e256c2 100644 --- a/studio/frontend/src/features/training/api/models-api.ts +++ b/studio/frontend/src/features/training/api/models-api.ts @@ -79,7 +79,7 @@ export interface LocalModelInfo { id: string; display_name: string; path: string; - source: "models_dir" | "hf_cache"; + source: "models_dir" | "hf_cache" | "lmstudio"; model_id?: string | null; updated_at?: number | null; } @@ -87,6 +87,7 @@ export interface LocalModelInfo { interface LocalModelListResponse { models_dir: string; hf_cache_dir?: string | null; + lmstudio_dirs?: string[]; models: LocalModelInfo[]; } From 1f498a73e6b0a7c9cb83d791f869623ebf9429f2 Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 25 Mar 2026 13:35:03 +0000 Subject: [PATCH 10/94] Revert "feat: multi-source model discovery (HF default, legacy cache, LM Studio)" This reverts commit d56b115bb4f27e712f1d78b662962b251fc73c62. --- studio/backend/models/models.py | 6 +- studio/backend/routes/models.py | 230 +++++------------- studio/backend/utils/paths/__init__.py | 4 - studio/backend/utils/paths/storage_roots.py | 50 +--- .../studio/sections/model-section.tsx | 6 +- .../src/features/training/api/models-api.ts | 3 +- 6 files changed, 76 insertions(+), 223 deletions(-) diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index 046e36137d..daa8eec907 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -165,7 +165,7 @@ class LocalModelInfo(BaseModel): id: str = Field(..., description = "Identifier to use for loading/training") display_name: str = Field(..., description = "Display label") path: str = Field(..., description = "Local path where model data was discovered") - source: Literal["models_dir", "hf_cache", "lmstudio"] = Field( + source: Literal["models_dir", "hf_cache"] = Field( ..., description = "Discovery source", ) @@ -189,10 +189,6 @@ class LocalModelListResponse(BaseModel): None, description = "HF cache root that was scanned", ) - lmstudio_dirs: List[str] = Field( - default_factory = list, - description = "LM Studio model directories that were scanned", - ) models: List[LocalModelInfo] = Field( default_factory = list, description = "Discovered local/cached models", diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 63c9304a64..e705762447 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -210,76 +210,6 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]: return found -def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: - """Scan an LM Studio models directory for model files. - - LM Studio uses a ``publisher/model-name`` folder structure containing - GGUF files, or standalone GGUF files at the top level. - """ - if not lm_dir.exists() or not lm_dir.is_dir(): - return [] - - found: List[LocalModelInfo] = [] - for child in lm_dir.iterdir(): - if not child.is_dir(): - if child.suffix == ".gguf" and child.is_file(): - try: - updated_at = child.stat().st_mtime - except OSError: - updated_at = None - found.append( - LocalModelInfo( - id = str(child), - display_name = child.stem, - path = str(child), - source = "lmstudio", - updated_at = updated_at, - ), - ) - continue - - # child is a publisher directory — scan its sub-directories - for model_dir in child.iterdir(): - if model_dir.is_dir(): - has_model = ( - any(model_dir.glob("*.gguf")) - or (model_dir / "config.json").exists() - or any(model_dir.glob("*.safetensors")) - ) - if not has_model: - continue - model_id = f"{child.name}/{model_dir.name}" - try: - updated_at = model_dir.stat().st_mtime - except OSError: - updated_at = None - found.append( - LocalModelInfo( - id = model_id, - model_id = model_id, - display_name = model_dir.name, - path = str(model_dir), - source = "lmstudio", - updated_at = updated_at, - ), - ) - elif model_dir.suffix == ".gguf" and model_dir.is_file(): - try: - updated_at = model_dir.stat().st_mtime - except OSError: - updated_at = None - found.append( - LocalModelInfo( - id = str(model_dir), - display_name = model_dir.stem, - path = str(model_dir), - source = "lmstudio", - updated_at = updated_at, - ), - ) - return found - - @router.get("/local", response_model = LocalModelListResponse) async def list_local_models( models_dir: str = Query( @@ -288,24 +218,13 @@ async def list_local_models( current_subject: str = Depends(get_current_subject), ): """ - List local model candidates from custom models dir, HF cache, - legacy Unsloth HF cache, and LM Studio directories. + List local model candidates from custom models dir and HF cache. """ - from utils.paths import legacy_hf_cache_dir, lmstudio_model_dirs - - # Resolve all scan directories up front. - hf_cache_dir = _resolve_hf_cache_dir() - legacy_hf = legacy_hf_cache_dir() - lm_dirs = lmstudio_model_dirs() - # Validate models_dir against an allowlist of trusted directories. # Only the trusted Path objects are used for filesystem access -- the # user-supplied string is only used for matching, never for path construction. - allowed_roots: list[Path] = [Path("./models").resolve(), hf_cache_dir] - if legacy_hf.is_dir(): - allowed_roots.append(legacy_hf) - for d in lm_dirs: - allowed_roots.append(d) + hf_cache_dir = _resolve_hf_cache_dir() + allowed_roots = [Path("./models").resolve(), hf_cache_dir] try: from utils.paths import studio_root, outputs_root @@ -329,14 +248,6 @@ async def list_local_models( try: local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir) - # Scan legacy Unsloth HF cache for backward compatibility - if legacy_hf.is_dir() and legacy_hf.resolve() != hf_cache_dir.resolve(): - local_models += _scan_hf_cache(legacy_hf) - - # Scan LM Studio directories - for lm_dir in lm_dirs: - local_models += _scan_lmstudio_dir(lm_dir) - deduped: dict[str, LocalModelInfo] = {} for model in local_models: if model.id not in deduped: @@ -351,7 +262,6 @@ async def list_local_models( return LocalModelListResponse( models_dir = str(models_root), hf_cache_dir = str(hf_cache_dir), - lmstudio_dirs = [str(d) for d in lm_dirs], models = models, ) except Exception as e: @@ -940,44 +850,42 @@ def _get_repo_size_cached(repo_id: str) -> int: async def list_cached_gguf( current_subject: str = Depends(get_current_subject), ): - """List GGUF repos downloaded to HF cache and legacy Unsloth cache.""" + """List GGUF repos that have already been downloaded to the HF cache. + + Uses scan_cache_dir() for proper repo IDs, then deduplicates by + lowercased key (HF cache dirs are lowercased but the canonical repo + ID preserves casing). + """ try: from huggingface_hub import scan_cache_dir - from utils.paths import legacy_hf_cache_dir - - cache_scans = [scan_cache_dir()] - legacy_hf = legacy_hf_cache_dir() - if legacy_hf.is_dir(): - try: - cache_scans.append(scan_cache_dir(cache_dir = str(legacy_hf))) - except Exception: - pass + hf_cache = scan_cache_dir() seen_lower: dict[str, dict] = {} - for hf_cache in cache_scans: - for repo_info in hf_cache.repos: - if repo_info.repo_type != "model": - continue - repo_id = repo_info.repo_id - if not repo_id.upper().endswith("-GGUF"): - continue - total_size = 0 - has_gguf = False - for revision in repo_info.revisions: - for f in revision.files: - if f.file_name.endswith(".gguf"): - has_gguf = True - total_size += f.size_on_disk - if not has_gguf: - continue - key = repo_id.lower() - existing = seen_lower.get(key) - if existing is None or total_size > existing["size_bytes"]: - seen_lower[key] = { - "repo_id": repo_id, - "size_bytes": total_size, - "cache_path": str(repo_info.repo_path), - } + for repo_info in hf_cache.repos: + if repo_info.repo_type != "model": + continue + repo_id = repo_info.repo_id + if not repo_id.upper().endswith("-GGUF"): + continue + # Check for actual .gguf files and sum sizes + total_size = 0 + has_gguf = False + for revision in repo_info.revisions: + for f in revision.files: + if f.file_name.endswith(".gguf"): + has_gguf = True + total_size += f.size_on_disk + if not has_gguf: + continue + # Deduplicate: keep the entry with the most data + key = repo_id.lower() + existing = seen_lower.get(key) + if existing is None or total_size > existing["size_bytes"]: + seen_lower[key] = { + "repo_id": repo_id, + "size_bytes": total_size, + "cache_path": str(repo_info.repo_path), + } cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"]) return {"cached": cached} except Exception as e: @@ -989,48 +897,44 @@ async def list_cached_gguf( async def list_cached_models( current_subject: str = Depends(get_current_subject), ): - """List non-GGUF model repos downloaded to HF cache and legacy Unsloth cache.""" + """List non-GGUF model repos that have been downloaded to the HF cache. + + Only includes repos that actually contain model weight files + (.safetensors, .bin), not repos with only config/metadata. + """ _WEIGHT_EXTENSIONS = (".safetensors", ".bin") try: from huggingface_hub import scan_cache_dir - from utils.paths import legacy_hf_cache_dir - - cache_scans = [scan_cache_dir()] - legacy_hf = legacy_hf_cache_dir() - if legacy_hf.is_dir(): - try: - cache_scans.append(scan_cache_dir(cache_dir = str(legacy_hf))) - except Exception: - pass + hf_cache = scan_cache_dir() seen_lower: dict[str, dict] = {} - for hf_cache in cache_scans: - for repo_info in hf_cache.repos: - if repo_info.repo_type != "model": - continue - repo_id = repo_info.repo_id - if repo_id.upper().endswith("-GGUF"): - continue - total_size = sum( - f.size_on_disk for rev in repo_info.revisions for f in rev.files - ) - if total_size == 0: - continue - has_weights = any( - f.file_name.endswith(_WEIGHT_EXTENSIONS) - for rev in repo_info.revisions - for f in rev.files - ) - if not has_weights: - continue - key = repo_id.lower() - existing = seen_lower.get(key) - if existing is None or total_size > existing["size_bytes"]: - seen_lower[key] = { - "repo_id": repo_id, - "size_bytes": total_size, - } + for repo_info in hf_cache.repos: + if repo_info.repo_type != "model": + continue + repo_id = repo_info.repo_id + if repo_id.upper().endswith("-GGUF"): + continue + total_size = sum( + f.size_on_disk for rev in repo_info.revisions for f in rev.files + ) + if total_size == 0: + continue + # Skip repos that only have config/metadata files (no weights) + has_weights = any( + f.file_name.endswith(_WEIGHT_EXTENSIONS) + for rev in repo_info.revisions + for f in rev.files + ) + if not has_weights: + continue + key = repo_id.lower() + existing = seen_lower.get(key) + if existing is None or total_size > existing["size_bytes"]: + seen_lower[key] = { + "repo_id": repo_id, + "size_bytes": total_size, + } cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"]) return {"cached": cached} except Exception as e: diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index aec6bb1292..789052f372 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -23,8 +23,6 @@ from .storage_roots import ( unstructured_uploads_root, oxc_validator_tmp_root, tensorboard_root, - legacy_hf_cache_dir, - lmstudio_model_dirs, ensure_dir, ensure_studio_directories, resolve_under_root, @@ -55,8 +53,6 @@ __all__ = [ "unstructured_uploads_root", "oxc_validator_tmp_root", "tensorboard_root", - "legacy_hf_cache_dir", - "lmstudio_model_dirs", "ensure_dir", "ensure_studio_directories", "resolve_under_root", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 08d3744e95..626e868275 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -3,9 +3,7 @@ from __future__ import annotations -import json import os -import sys from pathlib import Path import tempfile @@ -84,55 +82,19 @@ def ensure_dir(path: Path) -> Path: return path -def legacy_hf_cache_dir() -> Path: - """Old Unsloth-specific HF hub cache, kept for backward-compat scanning.""" - return cache_root() / "huggingface" / "hub" - - -def lmstudio_model_dirs() -> list[Path]: - """Return LM Studio model directories that exist on disk.""" - dirs: list[Path] = [] - - # 1. Check LM Studio settings.json for custom downloads folder - settings_path = Path.home() / ".lmstudio" / "settings.json" - if settings_path.is_file(): - try: - with open(settings_path) as f: - settings = json.load(f) - downloads = settings.get("downloadsFolder", "") - if downloads: - p = Path(downloads).expanduser() - if p.is_dir(): - dirs.append(p) - except Exception: - pass - - # 2. Legacy LM Studio cache (Linux/macOS) - if sys.platform == "win32": - legacy = Path.home() / ".cache" / "lm-studio" / "models" - else: - legacy = Path.home() / ".cache" / "lm-studio" / "models" - if legacy.is_dir(): - dirs.append(legacy) - - return dirs - - def _setup_cache_env() -> None: - """Set cache environment variables for uv and vLLM. - - HuggingFace cache variables (HF_HOME, HF_HUB_CACHE, HF_XET_CACHE) - are no longer overridden — HF uses its own defaults unless the user - has explicitly set them. The legacy Unsloth HF cache at - ``~/.unsloth/studio/cache/huggingface/hub`` is still scanned for - backward compatibility via :func:`legacy_hf_cache_dir`. + """Set cache environment variables for HuggingFace, uv, and vLLM. Only sets variables that are not already set by the user, so - explicit overrides are respected. + explicit overrides (e.g. HF_HOME=/data/hf) are respected. Works on Linux, macOS, and Windows. """ root = cache_root() + hf_dir = root / "huggingface" defaults = { + "HF_HOME": str(hf_dir), + "HF_HUB_CACHE": str(hf_dir / "hub"), + "HF_XET_CACHE": str(hf_dir / "xet"), "UV_CACHE_DIR": str(root / "uv"), "VLLM_CACHE_ROOT": str(root / "vllm"), } diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx index 84df506769..e9bcde17b5 100644 --- a/studio/frontend/src/features/studio/sections/model-section.tsx +++ b/studio/frontend/src/features/studio/sections/model-section.tsx @@ -334,11 +334,7 @@ export function ModelSection() { {(id: string) => { const model = localMetaById.get(id); const source = - model?.source === "hf_cache" - ? "HF cache" - : model?.source === "lmstudio" - ? "LM Studio" - : "Local dir"; + model?.source === "hf_cache" ? "HF cache" : "Local dir"; return ( diff --git a/studio/frontend/src/features/training/api/models-api.ts b/studio/frontend/src/features/training/api/models-api.ts index f2b1e256c2..84051e3e1d 100644 --- a/studio/frontend/src/features/training/api/models-api.ts +++ b/studio/frontend/src/features/training/api/models-api.ts @@ -79,7 +79,7 @@ export interface LocalModelInfo { id: string; display_name: string; path: string; - source: "models_dir" | "hf_cache" | "lmstudio"; + source: "models_dir" | "hf_cache"; model_id?: string | null; updated_at?: number | null; } @@ -87,7 +87,6 @@ export interface LocalModelInfo { interface LocalModelListResponse { models_dir: string; hf_cache_dir?: string | null; - lmstudio_dirs?: string[]; models: LocalModelInfo[]; } From 457c42964fd4f8d88fd9960cb2e2e7d1ad87747f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 06:38:32 -0700 Subject: [PATCH 11/94] fix(studio): validate bun install and retry from official source on failure (#4589) bun install (specifically the npm "bun" shim v1.3.x installed via npm install -g bun) can exit 0 while silently failing to install packages. This causes the frontend build to fail with "tsc: not found" or missing type declarations, since the fallback to npm only triggers on a non-zero exit code. Changes: 1. Initial bun install now tries the official bun.sh installer first (which gives a real bun runtime), falling back to npm install -g bun only if that fails. 2. After bun install reports success, verify that critical binaries (tsc, vite) actually exist in node_modules/.bin/. If they are missing, reinstall bun from the official source and retry once before falling back to npm. 3. Extract the bun install + validation logic into _try_bun_install() to avoid duplicating the check/cleanup across both attempts. --- studio/setup.sh | 77 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/studio/setup.sh b/studio/setup.sh index 4cfabec95e..90631f6131 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -164,17 +164,26 @@ fi echo "✅ Node $(node -v) | npm $(npm -v)" # ── Install bun (optional, faster package installs) ── -# Uses npm to install bun globally — Node is already guaranteed above, -# avoids platform-specific installers, PATH issues, and admin requirements. +# Try the official bun installer first (gives a real bun runtime). +# Fall back to npm install -g bun (gives a shim that may be outdated). +# If neither works, bun is simply skipped and npm handles everything. if ! command -v bun &>/dev/null; then echo " Installing bun (faster frontend package installs)..." - if npm install -g bun > /dev/null 2>&1 && command -v bun &>/dev/null; then - echo "✅ bun installed ($(bun --version))" + if curl -fsSL https://bun.sh/install 2>/dev/null | bash > /dev/null 2>&1; then + export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" + export PATH="$BUN_INSTALL/bin:$PATH" + fi + if ! command -v bun &>/dev/null; then + # Official installer failed or unavailable, try npm shim + npm install -g bun > /dev/null 2>&1 || true + fi + if command -v bun &>/dev/null; then + echo " bun installed ($(bun --version))" else echo " bun install skipped (npm will be used instead)" fi else - echo "✅ bun already installed ($(bun --version))" + echo " bun already installed ($(bun --version))" fi # ── 5. Build frontend ── @@ -202,24 +211,60 @@ _restore_gitignores() { trap _restore_gitignores EXIT # Use bun for install if available (faster), fall back to npm. -# Build always uses npm (Node runtime — avoids bun runtime issues on some platforms). +# Build always uses npm (Node runtime -- avoids bun runtime issues on some platforms). # NOTE: We intentionally avoid run_quiet for the bun install attempt because # run_quiet calls exit on failure, which would kill the script before the npm # fallback can run. Instead we capture output manually and only show it on failure. +# +# IMPORTANT: bun install can exit 0 but silently fail to install packages. +# The npm "bun" shim (v1.3.x) is known to do this. After bun install reports +# success, we verify that critical binaries (tsc, vite) actually landed in +# node_modules/.bin/. If they are missing we reinstall bun from the official +# source and retry once before falling back to npm. +_try_bun_install() { + local _log _exit_code=0 + _log=$(mktemp) + bun install >"$_log" 2>&1 || _exit_code=$? + + if [ "$_exit_code" -eq 0 ] && [ -x node_modules/.bin/tsc ] && [ -x node_modules/.bin/vite ]; then + rm -f "$_log" + return 0 + fi + + # Either bun install failed or it exited 0 but left packages missing + if [ "$_exit_code" -ne 0 ]; then + echo " bun install failed (exit code $_exit_code):" + else + echo " bun install exited 0 but critical binaries are missing:" + fi + sed 's/^/ | /' "$_log" >&2 + rm -f "$_log" + rm -rf node_modules + return 1 +} + +_bun_install_ok=false if command -v bun &>/dev/null; then echo " Using bun for package install (faster)" - _bun_log=$(mktemp) - if bun install >"$_bun_log" 2>&1; then - rm -f "$_bun_log" + if _try_bun_install; then + _bun_install_ok=true else - echo " ⚠️ bun install failed, falling back to npm" - echo " bun install output:" - sed 's/^/ | /' "$_bun_log" >&2 - rm -f "$_bun_log" - rm -rf node_modules - run_quiet "npm install" npm install + # First attempt failed -- try reinstalling bun from official source and retry + echo " Reinstalling bun from bun.sh and retrying..." + if curl -fsSL https://bun.sh/install 2>/dev/null | bash > /dev/null 2>&1; then + export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" + export PATH="$BUN_INSTALL/bin:$PATH" + hash -r 2>/dev/null || true + fi + if command -v bun &>/dev/null; then + echo " bun reinstalled ($(bun --version)), retrying..." + if _try_bun_install; then + _bun_install_ok=true + fi + fi fi -else +fi +if [ "$_bun_install_ok" = false ]; then run_quiet "npm install" npm install fi run_quiet "npm run build" npm run build From 2e4569e06a533d0d1a29774ffa7e9fa161df419a Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 07:05:02 -0700 Subject: [PATCH 12/94] fix(studio): clear bun cache on failure and retry before falling back to npm (#4594) bun's package cache can become corrupt, storing only package metadata (package.json, README) without actual content (bin/, lib/). When this happens, bun install exits 0 and reports packages as installed, but binaries like tsc are missing from node_modules/.bin/. For example, a corrupt typescript cache entry is 64KB (metadata only) vs 23MB when correctly downloaded. Changes: - After bun install, verify tsc and vite exist in node_modules/.bin/ - If missing, clear the bun cache with bun pm cache rm and retry once - Only fall back to npm if the retry also fails - Revert bun installation to npm install -g bun (the binary is fine, the cache was the problem) --- studio/setup.sh | 43 ++++++++++++++----------------------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/studio/setup.sh b/studio/setup.sh index 90631f6131..c095fc7245 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -164,20 +164,11 @@ fi echo "✅ Node $(node -v) | npm $(npm -v)" # ── Install bun (optional, faster package installs) ── -# Try the official bun installer first (gives a real bun runtime). -# Fall back to npm install -g bun (gives a shim that may be outdated). -# If neither works, bun is simply skipped and npm handles everything. +# Uses npm to install bun globally -- Node is already guaranteed above, +# avoids platform-specific installers, PATH issues, and admin requirements. if ! command -v bun &>/dev/null; then echo " Installing bun (faster frontend package installs)..." - if curl -fsSL https://bun.sh/install 2>/dev/null | bash > /dev/null 2>&1; then - export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" - export PATH="$BUN_INSTALL/bin:$PATH" - fi - if ! command -v bun &>/dev/null; then - # Official installer failed or unavailable, try npm shim - npm install -g bun > /dev/null 2>&1 || true - fi - if command -v bun &>/dev/null; then + if npm install -g bun > /dev/null 2>&1 && command -v bun &>/dev/null; then echo " bun installed ($(bun --version))" else echo " bun install skipped (npm will be used instead)" @@ -216,11 +207,11 @@ trap _restore_gitignores EXIT # run_quiet calls exit on failure, which would kill the script before the npm # fallback can run. Instead we capture output manually and only show it on failure. # -# IMPORTANT: bun install can exit 0 but silently fail to install packages. -# The npm "bun" shim (v1.3.x) is known to do this. After bun install reports -# success, we verify that critical binaries (tsc, vite) actually landed in -# node_modules/.bin/. If they are missing we reinstall bun from the official -# source and retry once before falling back to npm. +# IMPORTANT: bun's package cache can become corrupt -- packages get stored +# with only metadata (package.json, README) but no actual content (bin/, +# lib/). When this happens bun install exits 0 but leaves binaries missing. +# We verify critical binaries after install. If missing, we clear the cache +# and retry once before falling back to npm. _try_bun_install() { local _log _exit_code=0 _log=$(mktemp) @@ -249,18 +240,12 @@ if command -v bun &>/dev/null; then if _try_bun_install; then _bun_install_ok=true else - # First attempt failed -- try reinstalling bun from official source and retry - echo " Reinstalling bun from bun.sh and retrying..." - if curl -fsSL https://bun.sh/install 2>/dev/null | bash > /dev/null 2>&1; then - export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}" - export PATH="$BUN_INSTALL/bin:$PATH" - hash -r 2>/dev/null || true - fi - if command -v bun &>/dev/null; then - echo " bun reinstalled ($(bun --version)), retrying..." - if _try_bun_install; then - _bun_install_ok=true - fi + # First attempt failed, likely due to corrupt cache entries. + # Clear the cache and retry once. + echo " Clearing bun cache and retrying..." + bun pm cache rm > /dev/null 2>&1 || true + if _try_bun_install; then + _bun_install_ok=true fi fi fi From bc9cf314786041648a547722660379eb92e6c871 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 07:20:55 -0700 Subject: [PATCH 13/94] Pin torch>=2.4,<2.11.0 in Studio installers (#4595) torch 2.11.0 has a torch.compile/dynamo bug that causes a StopIteration crash in dict_keys_getitem when compiling MoE router functions (e.g. GptOssTopKRouter_forward). Pin to <2.11.0 until the upstream fix lands. Applies to both install.sh (Linux/macOS) and install.ps1 (Windows) fresh install paths. --- install.ps1 | 2 +- install.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index 83576d9c75..a4ed2658c9 100644 --- a/install.ps1 +++ b/install.ps1 @@ -583,7 +583,7 @@ shell.Run cmd, 0, False uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.11" unsloth-zoo } elseif ($TorchIndexUrl) { Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." - uv pip install --python $VenvPython torch torchvision torchaudio --index-url $TorchIndexUrl + uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] Failed to install PyTorch (exit code $LASTEXITCODE)" -ForegroundColor Red return diff --git a/install.sh b/install.sh index ec5008af12..6f60c23d27 100755 --- a/install.sh +++ b/install.sh @@ -775,7 +775,7 @@ if [ "$_MIGRATED" = true ]; then elif [ -n "$TORCH_INDEX_URL" ]; then # Fresh: Step 1 - install torch from explicit index echo "==> Installing PyTorch ($TORCH_INDEX_URL)..." - uv pip install --python "$_VENV_PY" torch torchvision torchaudio \ + uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \ --index-url "$TORCH_INDEX_URL" # Fresh: Step 2 - install unsloth, preserving pre-installed torch echo "==> Installing unsloth (this may take a few minutes)..." From 3efea63e2f52a0061ba37167833ee5fa09b99cc8 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 07:25:47 -0700 Subject: [PATCH 14/94] fix(studio): source-build fallback prefers Unsloth's tested tag over upstream latest (#4593) * fix(studio): source-build fallback prefers Unsloth's tested tag over upstream latest When the prebuilt install fails and falls back to source build, --resolve-llama-tag now queries the Unsloth release repo (unslothai/llama.cpp) first to get the latest tested/approved tag (e.g. b8508), instead of going straight to ggml-org/llama.cpp which may return a newer untested tag (e.g. b8514). This ensures the source-build fallback compiles the same version that the prebuilt path would have installed, rather than a potentially incompatible bleeding-edge release. Resolution order for "latest": 1. Unsloth release repo (tested/approved) 2. ggml-org upstream (bleeding-edge) 3. Raw requested tag string (last resort) Changes: - resolve_requested_llama_tag() accepts optional published_repo param with docstring explaining the resolution order - CLI --resolve-llama-tag passes --published-repo through - setup.sh and setup.ps1 pass --published-repo to --resolve-llama-tag with inline comments explaining the preference * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/install_llama_prebuilt.py | 34 +++++++++++++++++++++++++++++++- studio/setup.ps1 | 5 ++++- studio/setup.sh | 5 ++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index a9d0b72352..516dc4b6a4 100755 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -1285,9 +1285,39 @@ def pinned_published_release_bundle( def resolve_requested_llama_tag( requested_tag: str | None, + published_repo: str = "", ) -> str: + """Resolve a llama.cpp tag for source-build fallback. + + Resolution order: + 1. Concrete tag (e.g. "b8508") -- returned as-is. + 2. "latest" with published_repo -- query the Unsloth release repo + (e.g. unslothai/llama.cpp) for its latest release tag. This is the + tested/approved version that matches the prebuilt binaries. + 3. "latest" without published_repo or if (2) fails -- query the upstream + ggml-org/llama.cpp repo. This may return a newer, untested tag. + + The Unsloth repo is preferred because its releases are pinned to specific + upstream tags that have been validated with Unsloth Studio. Using the + upstream bleeding-edge tag risks API/ABI incompatibilities. + """ if requested_tag and requested_tag != "latest": return requested_tag + # Prefer the Unsloth release repo tag (tested/approved) over bleeding-edge + # upstream. For example, unslothai/llama.cpp may publish b8508 while + # ggml-org/llama.cpp latest is b8514. The source-build fallback should + # compile the same version the prebuilt path would have installed. + if published_repo: + try: + payload = fetch_json( + f"https://api.github.com/repos/{published_repo}/releases/latest" + ) + tag = payload.get("tag_name") + if isinstance(tag, str) and tag: + return tag + except Exception: + pass + # Fall back to upstream ggml-org latest release tag return latest_upstream_release_tag() @@ -3360,7 +3390,9 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() if args.resolve_llama_tag is not None: - print(resolve_requested_llama_tag(args.resolve_llama_tag)) + # Pass published_repo so the resolver prefers the Unsloth release tag + # (tested/approved) over the upstream ggml-org bleeding-edge tag. + print(resolve_requested_llama_tag(args.resolve_llama_tag, args.published_repo)) return EXIT_SUCCESS if args.resolve_install_tag is not None: diff --git a/studio/setup.ps1 b/studio/setup.ps1 index d8465fd039..4cd19fdb03 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -1314,7 +1314,10 @@ if ($resolveExit -ne 0 -or [string]::IsNullOrWhiteSpace($ResolvedLlamaTag)) { if ($resolveOutput) { $resolveOutput | ForEach-Object { Write-Host $_ } } - $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-llama-tag $RequestedLlamaTag 2>$null + # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo + # so the resolver prefers Unsloth's tested tag (e.g. b8508) over the upstream + # bleeding-edge tag (e.g. b8514) from ggml-org/llama.cpp. + $fallbackOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" --resolve-llama-tag $RequestedLlamaTag --published-repo $HelperReleaseRepo 2>$null $fallbackExit = $LASTEXITCODE $ResolvedLlamaTag = if ($fallbackExit -eq 0 -and $fallbackOutput) { ($fallbackOutput | Select-Object -Last 1).ToString().Trim() diff --git a/studio/setup.sh b/studio/setup.sh index c095fc7245..a7991b83be 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -397,7 +397,10 @@ if [ -z "$_RESOLVED_LLAMA_TAG" ]; then echo "⚠️ Failed to resolve an installable prebuilt llama.cpp tag via $_HELPER_RELEASE_REPO" cat "$_RESOLVE_LLAMA_LOG" >&2 || true set +e - _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" --resolve-llama-tag "$_REQUESTED_LLAMA_TAG" 2>/dev/null)" + # Resolve the llama.cpp tag for source-build fallback. Pass --published-repo + # so the resolver prefers Unsloth's tested tag (e.g. b8508) over the upstream + # bleeding-edge tag (e.g. b8514) from ggml-org/llama.cpp. + _RESOLVED_LLAMA_TAG="$(python "$SCRIPT_DIR/install_llama_prebuilt.py" --resolve-llama-tag "$_REQUESTED_LLAMA_TAG" --published-repo "$_HELPER_RELEASE_REPO" 2>/dev/null)" _RESOLVE_UPSTREAM_STATUS=$? set -e if [ "$_RESOLVE_UPSTREAM_STATUS" -ne 0 ] || [ -z "$_RESOLVED_LLAMA_TAG" ]; then From 366fb048d4cf13a2868dc45b9e7f8142845de8b9 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 07:27:08 -0700 Subject: [PATCH 15/94] fix(studio): add bun cache validation to Windows setup.ps1 (#4596) Port the bun cache corruption fix from setup.sh to setup.ps1. bun's package cache can become corrupt, storing only package metadata without actual content. This causes bun install to exit 0 but leave binaries like tsc missing from node_modules/.bin/. Changes: - After bun install, verify tsc and vite exist in node_modules\.bin\ - Check for both bare names and .cmd wrappers (Windows creates both) - If missing, clear the bun cache and retry once - Only fall back to npm if the retry also fails --- studio/setup.ps1 | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 4cd19fdb03..0ac54d3866 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -919,11 +919,37 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) { $UseBun = $null -ne (Get-Command bun -ErrorAction SilentlyContinue) + # bun's package cache can become corrupt -- packages get stored with only + # metadata but no actual content (bin/, lib/). When this happens bun install + # exits 0 but leaves binaries missing. We validate after install and clear + # the cache + retry once before falling back to npm. if ($UseBun) { Write-Host " Using bun for package install (faster)" -ForegroundColor DarkGray & bun install *> $null $bunExit = $LASTEXITCODE - if ($bunExit -ne 0) { + # On Windows, .bin/ entries can be tsc, tsc.cmd, or tsc.ps1 + $hasTsc = (Test-Path "node_modules\.bin\tsc") -or (Test-Path "node_modules\.bin\tsc.cmd") + $hasVite = (Test-Path "node_modules\.bin\vite") -or (Test-Path "node_modules\.bin\vite.cmd") + if ($bunExit -eq 0 -and $hasTsc -and $hasVite) { + # bun install succeeded and critical binaries are present + } elseif ($bunExit -eq 0) { + Write-Host " bun install exited 0 but critical binaries are missing, clearing cache and retrying..." -ForegroundColor Yellow + if (Test-Path "node_modules") { + Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue + } + & bun pm cache rm *> $null + & bun install *> $null + $bunExit = $LASTEXITCODE + $hasTsc = (Test-Path "node_modules\.bin\tsc") -or (Test-Path "node_modules\.bin\tsc.cmd") + $hasVite = (Test-Path "node_modules\.bin\vite") -or (Test-Path "node_modules\.bin\vite.cmd") + if ($bunExit -ne 0 -or -not $hasTsc -or -not $hasVite) { + Write-Host " bun retry failed, falling back to npm" -ForegroundColor Yellow + if (Test-Path "node_modules") { + Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue + } + $UseBun = $false + } + } else { Write-Host " [WARN] bun install failed (exit $bunExit), falling back to npm" -ForegroundColor Yellow if (Test-Path "node_modules") { Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue From ebe22c1e9ed12a801aee0a5fab4fb87c54de9c18 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 07:30:40 -0700 Subject: [PATCH 16/94] Update _utils.py --- unsloth/models/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 13acc98ea6..02e2170b70 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.3.11" +__version__ = "2026.3.12" __all__ = [ "SUPPORTS_BFLOAT16", From 48a78845844c3c8965437db1d7d8ad2bd8713244 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:48:04 +0400 Subject: [PATCH 17/94] feat: multi-source model discovery (HF default, legacy cache, LM Studio) (#4591) * feat: multi-source model discovery (HF default, legacy cache, LM Studio) * Fix multi-source model discovery bugs - Fix lmstudio_model_dirs: add ~/.lmstudio/models as default path, remove dead sys.platform branch, add dedup via seen set - Fix _setup_cache_env: preserve legacy HF cache env vars when the legacy hub directory exists and is non-empty - Fix _scan_lmstudio_dir: use absolute path for id field so is_local_path() returns True - Remove LM Studio dirs from allowed_roots (scanned unconditionally) - Replace bare except passes with logger.warning in legacy cache blocks - Fix delete_cached_model to search both default and legacy HF caches - Make lmstudio_dirs non-optional in TS interface (matches Python schema) - Exclude lmstudio source from trainable model filter - Remove unused import sys * Scan HF default cache alongside legacy and active caches When _setup_cache_env overrides HF_HUB_CACHE to the legacy Unsloth path, the standard HF default cache (~/.cache/huggingface/hub) was never scanned, hiding models downloaded before Unsloth Studio was installed. Add hf_default_cache_dir() and _all_hf_cache_scans() helper that deduplicates and scans all three HF cache locations (active, legacy, default). Used in list_local_models, list_cached_gguf, list_cached_models, and delete_cached_model. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/models/models.py | 6 +- studio/backend/routes/models.py | 271 +++++++++++++----- studio/backend/utils/paths/__init__.py | 6 + studio/backend/utils/paths/storage_roots.py | 64 ++++- .../studio/sections/model-section.tsx | 7 +- .../src/features/training/api/models-api.ts | 3 +- 6 files changed, 274 insertions(+), 83 deletions(-) diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py index daa8eec907..046e36137d 100644 --- a/studio/backend/models/models.py +++ b/studio/backend/models/models.py @@ -165,7 +165,7 @@ class LocalModelInfo(BaseModel): id: str = Field(..., description = "Identifier to use for loading/training") display_name: str = Field(..., description = "Display label") path: str = Field(..., description = "Local path where model data was discovered") - source: Literal["models_dir", "hf_cache"] = Field( + source: Literal["models_dir", "hf_cache", "lmstudio"] = Field( ..., description = "Discovery source", ) @@ -189,6 +189,10 @@ class LocalModelListResponse(BaseModel): None, description = "HF cache root that was scanned", ) + lmstudio_dirs: List[str] = Field( + default_factory = list, + description = "LM Studio model directories that were scanned", + ) models: List[LocalModelInfo] = Field( default_factory = list, description = "Discovered local/cached models", diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index e705762447..f76034c95b 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -210,6 +210,76 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]: return found +def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: + """Scan an LM Studio models directory for model files. + + LM Studio uses a ``publisher/model-name`` folder structure containing + GGUF files, or standalone GGUF files at the top level. + """ + if not lm_dir.exists() or not lm_dir.is_dir(): + return [] + + found: List[LocalModelInfo] = [] + for child in lm_dir.iterdir(): + if not child.is_dir(): + if child.suffix == ".gguf" and child.is_file(): + try: + updated_at = child.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(child), + display_name = child.stem, + path = str(child), + source = "lmstudio", + updated_at = updated_at, + ), + ) + continue + + # child is a publisher directory — scan its sub-directories + for model_dir in child.iterdir(): + if model_dir.is_dir(): + has_model = ( + any(model_dir.glob("*.gguf")) + or (model_dir / "config.json").exists() + or any(model_dir.glob("*.safetensors")) + ) + if not has_model: + continue + model_id = f"{child.name}/{model_dir.name}" + try: + updated_at = model_dir.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(model_dir), + model_id = model_id, + display_name = model_dir.name, + path = str(model_dir), + source = "lmstudio", + updated_at = updated_at, + ), + ) + elif model_dir.suffix == ".gguf" and model_dir.is_file(): + try: + updated_at = model_dir.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(model_dir), + display_name = model_dir.stem, + path = str(model_dir), + source = "lmstudio", + updated_at = updated_at, + ), + ) + return found + + @router.get("/local", response_model = LocalModelListResponse) async def list_local_models( models_dir: str = Query( @@ -218,13 +288,29 @@ async def list_local_models( current_subject: str = Depends(get_current_subject), ): """ - List local model candidates from custom models dir and HF cache. + List local model candidates from custom models dir, HF cache, + legacy Unsloth HF cache, and LM Studio directories. """ + from utils.paths import ( + legacy_hf_cache_dir, + hf_default_cache_dir, + lmstudio_model_dirs, + ) + + # Resolve all scan directories up front. + hf_cache_dir = _resolve_hf_cache_dir() + legacy_hf = legacy_hf_cache_dir() + hf_default = hf_default_cache_dir() + lm_dirs = lmstudio_model_dirs() + # Validate models_dir against an allowlist of trusted directories. # Only the trusted Path objects are used for filesystem access -- the # user-supplied string is only used for matching, never for path construction. - hf_cache_dir = _resolve_hf_cache_dir() - allowed_roots = [Path("./models").resolve(), hf_cache_dir] + allowed_roots: list[Path] = [Path("./models").resolve(), hf_cache_dir] + if legacy_hf.is_dir(): + allowed_roots.append(legacy_hf) + if hf_default.is_dir(): + allowed_roots.append(hf_default) try: from utils.paths import studio_root, outputs_root @@ -248,6 +334,22 @@ async def list_local_models( try: local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir) + # Scan legacy Unsloth HF cache for backward compatibility + if legacy_hf.is_dir() and legacy_hf.resolve() != hf_cache_dir.resolve(): + local_models += _scan_hf_cache(legacy_hf) + + # Scan HF system default cache (may differ when env vars are overridden) + if ( + hf_default.is_dir() + and hf_default.resolve() != hf_cache_dir.resolve() + and hf_default.resolve() != legacy_hf.resolve() + ): + local_models += _scan_hf_cache(hf_default) + + # Scan LM Studio directories + for lm_dir in lm_dirs: + local_models += _scan_lmstudio_dir(lm_dir) + deduped: dict[str, LocalModelInfo] = {} for model in local_models: if model.id not in deduped: @@ -262,6 +364,7 @@ async def list_local_models( return LocalModelListResponse( models_dir = str(models_root), hf_cache_dir = str(hf_cache_dir), + lmstudio_dirs = [str(d) for d in lm_dirs], models = models, ) except Exception as e: @@ -846,46 +949,65 @@ def _get_repo_size_cached(repo_id: str) -> int: return 0 +def _all_hf_cache_scans(): + """Return scan_cache_dir results for the active, legacy, and default HF caches.""" + from huggingface_hub import scan_cache_dir + from utils.paths import legacy_hf_cache_dir, hf_default_cache_dir + + scans = [scan_cache_dir()] + seen: set[str] = set() + try: + # Resolve the active cache dir so we can dedup + from huggingface_hub.constants import HF_HUB_CACHE + + seen.add(str(Path(HF_HUB_CACHE).resolve())) + except Exception: + pass + + for extra_fn in (legacy_hf_cache_dir, hf_default_cache_dir): + extra = extra_fn() + if extra.is_dir() and str(extra.resolve()) not in seen: + seen.add(str(extra.resolve())) + try: + scans.append(scan_cache_dir(cache_dir = str(extra))) + except Exception as exc: + logger.warning("Could not scan HF cache %s: %s", extra, exc) + return scans + + @router.get("/cached-gguf") async def list_cached_gguf( current_subject: str = Depends(get_current_subject), ): - """List GGUF repos that have already been downloaded to the HF cache. - - Uses scan_cache_dir() for proper repo IDs, then deduplicates by - lowercased key (HF cache dirs are lowercased but the canonical repo - ID preserves casing). - """ + """List GGUF repos downloaded to HF cache, legacy Unsloth cache, and HF default cache.""" try: - from huggingface_hub import scan_cache_dir + cache_scans = _all_hf_cache_scans() - hf_cache = scan_cache_dir() seen_lower: dict[str, dict] = {} - for repo_info in hf_cache.repos: - if repo_info.repo_type != "model": - continue - repo_id = repo_info.repo_id - if not repo_id.upper().endswith("-GGUF"): - continue - # Check for actual .gguf files and sum sizes - total_size = 0 - has_gguf = False - for revision in repo_info.revisions: - for f in revision.files: - if f.file_name.endswith(".gguf"): - has_gguf = True - total_size += f.size_on_disk - if not has_gguf: - continue - # Deduplicate: keep the entry with the most data - key = repo_id.lower() - existing = seen_lower.get(key) - if existing is None or total_size > existing["size_bytes"]: - seen_lower[key] = { - "repo_id": repo_id, - "size_bytes": total_size, - "cache_path": str(repo_info.repo_path), - } + for hf_cache in cache_scans: + for repo_info in hf_cache.repos: + if repo_info.repo_type != "model": + continue + repo_id = repo_info.repo_id + if not repo_id.upper().endswith("-GGUF"): + continue + total_size = 0 + has_gguf = False + for revision in repo_info.revisions: + for f in revision.files: + if f.file_name.endswith(".gguf"): + has_gguf = True + total_size += f.size_on_disk + if not has_gguf: + continue + key = repo_id.lower() + existing = seen_lower.get(key) + if existing is None or total_size > existing["size_bytes"]: + seen_lower[key] = { + "repo_id": repo_id, + "size_bytes": total_size, + "cache_path": str(repo_info.repo_path), + } cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"]) return {"cached": cached} except Exception as e: @@ -897,44 +1019,39 @@ async def list_cached_gguf( async def list_cached_models( current_subject: str = Depends(get_current_subject), ): - """List non-GGUF model repos that have been downloaded to the HF cache. - - Only includes repos that actually contain model weight files - (.safetensors, .bin), not repos with only config/metadata. - """ + """List non-GGUF model repos downloaded to HF cache, legacy Unsloth cache, and HF default cache.""" _WEIGHT_EXTENSIONS = (".safetensors", ".bin") try: - from huggingface_hub import scan_cache_dir + cache_scans = _all_hf_cache_scans() - hf_cache = scan_cache_dir() seen_lower: dict[str, dict] = {} - for repo_info in hf_cache.repos: - if repo_info.repo_type != "model": - continue - repo_id = repo_info.repo_id - if repo_id.upper().endswith("-GGUF"): - continue - total_size = sum( - f.size_on_disk for rev in repo_info.revisions for f in rev.files - ) - if total_size == 0: - continue - # Skip repos that only have config/metadata files (no weights) - has_weights = any( - f.file_name.endswith(_WEIGHT_EXTENSIONS) - for rev in repo_info.revisions - for f in rev.files - ) - if not has_weights: - continue - key = repo_id.lower() - existing = seen_lower.get(key) - if existing is None or total_size > existing["size_bytes"]: - seen_lower[key] = { - "repo_id": repo_id, - "size_bytes": total_size, - } + for hf_cache in cache_scans: + for repo_info in hf_cache.repos: + if repo_info.repo_type != "model": + continue + repo_id = repo_info.repo_id + if repo_id.upper().endswith("-GGUF"): + continue + total_size = sum( + f.size_on_disk for rev in repo_info.revisions for f in rev.files + ) + if total_size == 0: + continue + has_weights = any( + f.file_name.endswith(_WEIGHT_EXTENSIONS) + for rev in repo_info.revisions + for f in rev.files + ) + if not has_weights: + continue + key = repo_id.lower() + existing = seen_lower.get(key) + if existing is None or total_size > existing["size_bytes"]: + seen_lower[key] = { + "repo_id": repo_id, + "size_bytes": total_size, + } cached = sorted(seen_lower.values(), key = lambda c: c["repo_id"]) return {"cached": cached} except Exception as e: @@ -989,15 +1106,17 @@ async def delete_cached_model( pass try: - from huggingface_hub import scan_cache_dir + cache_scans = _all_hf_cache_scans() - hf_cache = scan_cache_dir() target_repo = None - for repo_info in hf_cache.repos: - if repo_info.repo_type != "model": - continue - if repo_info.repo_id.lower() == repo_id.lower(): - target_repo = repo_info + for hf_cache in cache_scans: + for repo_info in hf_cache.repos: + if repo_info.repo_type != "model": + continue + if repo_info.repo_id.lower() == repo_id.lower(): + target_repo = repo_info + break + if target_repo is not None: break if target_repo is None: diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 789052f372..44a7c8e287 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -23,6 +23,9 @@ from .storage_roots import ( unstructured_uploads_root, oxc_validator_tmp_root, tensorboard_root, + legacy_hf_cache_dir, + hf_default_cache_dir, + lmstudio_model_dirs, ensure_dir, ensure_studio_directories, resolve_under_root, @@ -53,6 +56,9 @@ __all__ = [ "unstructured_uploads_root", "oxc_validator_tmp_root", "tensorboard_root", + "legacy_hf_cache_dir", + "hf_default_cache_dir", + "lmstudio_model_dirs", "ensure_dir", "ensure_studio_directories", "resolve_under_root", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 626e868275..9bcf3758ad 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import os from pathlib import Path import tempfile @@ -82,22 +83,77 @@ def ensure_dir(path: Path) -> Path: return path +def legacy_hf_cache_dir() -> Path: + """Old Unsloth-specific HF hub cache, kept for backward-compat scanning.""" + return cache_root() / "huggingface" / "hub" + + +def hf_default_cache_dir() -> Path: + """Return the platform default HuggingFace hub cache (ignoring env overrides). + + This is the location HF uses when no ``HF_HUB_CACHE`` / ``HF_HOME`` + env var is set. We scan it so that models a user downloaded *before* + installing Unsloth Studio are still discovered. + """ + return Path.home() / ".cache" / "huggingface" / "hub" + + +def lmstudio_model_dirs() -> list[Path]: + """Return LM Studio model directories that exist on disk.""" + dirs: list[Path] = [] + seen: set[Path] = set() + + def _add(p: Path) -> None: + resolved = p.resolve() + if resolved not in seen and p.is_dir(): + seen.add(resolved) + dirs.append(p) + + # 1. Check LM Studio settings.json for custom downloads folder + settings_path = Path.home() / ".lmstudio" / "settings.json" + if settings_path.is_file(): + try: + with open(settings_path) as f: + settings = json.load(f) + downloads = settings.get("downloadsFolder", "") + if downloads: + _add(Path(downloads).expanduser()) + except Exception: + pass + + # 2. LM Studio current default models directory (all platforms) + _add(Path.home() / ".lmstudio" / "models") + + # 3. Legacy LM Studio cache location + _add(Path.home() / ".cache" / "lm-studio" / "models") + + return dirs + + def _setup_cache_env() -> None: """Set cache environment variables for HuggingFace, uv, and vLLM. + HuggingFace cache variables are only set when the legacy Unsloth HF + cache already exists, preserving existing model locations. New + installations leave HF at its own defaults. + Only sets variables that are not already set by the user, so explicit overrides (e.g. HF_HOME=/data/hf) are respected. Works on Linux, macOS, and Windows. """ root = cache_root() hf_dir = root / "huggingface" - defaults = { - "HF_HOME": str(hf_dir), - "HF_HUB_CACHE": str(hf_dir / "hub"), - "HF_XET_CACHE": str(hf_dir / "xet"), + defaults: dict[str, str] = { "UV_CACHE_DIR": str(root / "uv"), "VLLM_CACHE_ROOT": str(root / "vllm"), } + # Preserve legacy HF cache for existing installations + legacy_hub = hf_dir / "hub" + if legacy_hub.is_dir() and any(legacy_hub.iterdir()): + defaults["HF_HOME"] = str(hf_dir) + defaults["HF_HUB_CACHE"] = str(legacy_hub) + defaults["HF_XET_CACHE"] = str(hf_dir / "xet") + for key, value in defaults.items(): if key not in os.environ: os.environ[key] = value diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx index e9bcde17b5..9dccae3bc3 100644 --- a/studio/frontend/src/features/studio/sections/model-section.tsx +++ b/studio/frontend/src/features/studio/sections/model-section.tsx @@ -181,6 +181,7 @@ export function ModelSection() { const trainableLocalModels = useMemo( () => localModels.filter((m) => { + if (m.source === "lmstudio") return false; if (m.path.endsWith(".gguf")) return false; if (m.id.toLowerCase().includes("-gguf")) return false; return true; @@ -334,7 +335,11 @@ export function ModelSection() { {(id: string) => { const model = localMetaById.get(id); const source = - model?.source === "hf_cache" ? "HF cache" : "Local dir"; + model?.source === "hf_cache" + ? "HF cache" + : model?.source === "lmstudio" + ? "LM Studio" + : "Local dir"; return ( diff --git a/studio/frontend/src/features/training/api/models-api.ts b/studio/frontend/src/features/training/api/models-api.ts index 84051e3e1d..2a9ad7c0d6 100644 --- a/studio/frontend/src/features/training/api/models-api.ts +++ b/studio/frontend/src/features/training/api/models-api.ts @@ -79,7 +79,7 @@ export interface LocalModelInfo { id: string; display_name: string; path: string; - source: "models_dir" | "hf_cache"; + source: "models_dir" | "hf_cache" | "lmstudio"; model_id?: string | null; updated_at?: number | null; } @@ -87,6 +87,7 @@ export interface LocalModelInfo { interface LocalModelListResponse { models_dir: string; hf_cache_dir?: string | null; + lmstudio_dirs: string[]; models: LocalModelInfo[]; } From 2683c2ab583082aa16f3f23539ce7b569e0de901 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 08:00:44 -0700 Subject: [PATCH 18/94] Add unsloth to User PATH on Windows after install (#4597) After installation, `unsloth studio` only works if the user activates the Studio venv first or uses the full absolute path. The Desktop/Start Menu shortcuts work fine, but typing `unsloth studio` in a fresh terminal does not. This adds the venv Scripts dir to the persistent User PATH env var (if not already present) so `unsloth studio` works from any new terminal window. The current session is also updated via the existing Refresh-SessionPath helper. --- install.ps1 | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/install.ps1 b/install.ps1 index a4ed2658c9..ed656850d4 100644 --- a/install.ps1 +++ b/install.ps1 @@ -623,6 +623,19 @@ shell.Run cmd, 0, False New-StudioShortcuts -UnslothExePath $UnslothExe + # ── Add venv Scripts dir to User PATH so `unsloth studio` works from any terminal ── + $ScriptsDir = Join-Path $VenvDir "Scripts" + $UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User") + if (-not $UserPath -or $UserPath -notlike "*$ScriptsDir*") { + if ($UserPath) { + [System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir;$UserPath", "User") + } else { + [System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir", "User") + } + Refresh-SessionPath + Write-Host "[OK] Added unsloth to PATH" -ForegroundColor Green + } + Write-Host "" Write-Host "=========================================" Write-Host " Unsloth Studio installed!" From 289c7dd7bb20d95d96bc8dd237cf9d3f12293beb Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 15:12:56 +0000 Subject: [PATCH 19/94] Add --local and --package flags to install.ps1 Windows install.ps1 had no way to install from a local repo checkout, unlike install.sh which supports ./install.sh --local. This adds: - --local: install from the local repo via editable install (-e . --no-deps) after installing deps from PyPI, mirroring install.sh behavior - --package: install a different package name for testing The --local flag: 1. Validates pyproject.toml exists at the script's directory 2. Installs torch + unsloth deps normally 3. Overlays the local checkout with uv pip install -e --no-deps 4. Passes STUDIO_LOCAL_INSTALL and STUDIO_LOCAL_REPO to setup.ps1 --- install.ps1 | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/install.ps1 b/install.ps1 index ed656850d4..9405072bd7 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,10 +1,37 @@ # Unsloth Studio Installer for Windows PowerShell # Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/install.ps1 | iex -# Local: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\install.ps1 +# Local: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\install.ps1 --local +# Test: .\install.ps1 --package roland-sloth function Install-UnslothStudio { $ErrorActionPreference = "Stop" + # ── Parse flags ── + $StudioLocalInstall = $false + $PackageName = "unsloth" + $RepoRoot = "" + $argList = $args + for ($i = 0; $i -lt $argList.Count; $i++) { + switch ($argList[$i]) { + "--local" { $StudioLocalInstall = $true } + "--package" { + $i++ + if ($i -ge $argList.Count) { + Write-Host "[ERROR] --package requires an argument." -ForegroundColor Red + return + } + $PackageName = $argList[$i] + } + } + } + if ($StudioLocalInstall) { + $RepoRoot = (Resolve-Path (Split-Path -Parent $PSCommandPath)).Path + if (-not (Test-Path (Join-Path $RepoRoot "pyproject.toml"))) { + Write-Host "[ERROR] --local must be run from the unsloth repo root (pyproject.toml not found at $RepoRoot)" -ForegroundColor Red + return + } + } + $PythonVersion = "3.13" $StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio" $VenvDir = Join-Path $StudioHome "unsloth_studio" @@ -581,6 +608,10 @@ shell.Run cmd, 0, False # in the new venv location, while preserving existing torch/CUDA Write-Host "==> Upgrading unsloth in migrated environment..." uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.11" unsloth-zoo + if ($StudioLocalInstall) { + Write-Host "==> Overlaying local repo (editable)..." + uv pip install --python $VenvPython -e $RepoRoot --no-deps + } } elseif ($TorchIndexUrl) { Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl @@ -590,11 +621,23 @@ shell.Run cmd, 0, False } Write-Host "==> Installing unsloth (this may take a few minutes)..." - uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.11" + if ($StudioLocalInstall) { + uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.11" unsloth-zoo + Write-Host "==> Overlaying local repo (editable)..." + uv pip install --python $VenvPython -e $RepoRoot --no-deps + } else { + uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" + } } else { # Fallback: GPU detection failed to produce a URL -- let uv resolve torch Write-Host "==> Installing unsloth (this may take a few minutes)..." - uv pip install --python $VenvPython "unsloth>=2026.3.11" --torch-backend=auto + if ($StudioLocalInstall) { + uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.11" --torch-backend=auto + Write-Host "==> Overlaying local repo (editable)..." + uv pip install --python $VenvPython -e $RepoRoot --no-deps + } else { + uv pip install --python $VenvPython "$PackageName" --torch-backend=auto + } } if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $LASTEXITCODE)" -ForegroundColor Red @@ -615,6 +658,11 @@ shell.Run cmd, 0, False } # Tell setup.ps1 to skip base package installation (install.ps1 already did it) $env:SKIP_STUDIO_BASE = "1" + $env:STUDIO_PACKAGE_NAME = $PackageName + if ($StudioLocalInstall) { + $env:STUDIO_LOCAL_INSTALL = "1" + $env:STUDIO_LOCAL_REPO = $RepoRoot + } & $UnslothExe studio setup if ($LASTEXITCODE -ne 0) { Write-Host "[ERROR] unsloth studio setup failed (exit code $LASTEXITCODE)" -ForegroundColor Red From 561f0f39be429e93340a4b72ead4488fd81bcdf1 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 15:14:51 +0000 Subject: [PATCH 20/94] Fix install.ps1 --local: pass script args to Install-UnslothStudio The function was called with no arguments, so $args inside the function was always empty. Script-level args (--local, --package) were never forwarded. Use @args splatting to pass them through. --- install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 9405072bd7..bb0acf1237 100644 --- a/install.ps1 +++ b/install.ps1 @@ -706,4 +706,4 @@ shell.Run cmd, 0, False } } -Install-UnslothStudio +Install-UnslothStudio @args From 6d6008a1ef78af2cf4d73be711f1f0fcb8dd6f9d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 08:27:27 -0700 Subject: [PATCH 21/94] Add PID file tracking and `unsloth studio stop` command (#4598) * Add PID file tracking and `unsloth studio stop` command On macOS the .app shortcut launches Studio via osascript into a Terminal window, then the launcher script exits. The server process runs outside of the launcher's context with no PID file, so there is no straightforward way to find or stop it. This adds: - PID file at ~/.unsloth/studio/studio.pid, written after the server starts and removed on graceful shutdown or via atexit - `unsloth studio stop` command that reads the PID file and sends SIGTERM (or taskkill on Windows) to shut down the server The PID file is only removed if it still contains the current process ID, avoiding races when a new server instance replaces a crashed one. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move atexit PID cleanup into run_server() The atexit registration was only in the __main__ block, so it did not cover the `unsloth studio` CLI path that calls run_server() directly via studio_default(). Moving it into run_server() ensures the PID file is cleaned up on unexpected exit regardless of entry point. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/run.py | 29 +++++++++++++++ unsloth_cli/commands/studio.py | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/studio/backend/run.py b/studio/backend/run.py index e32b912c37..b892037565 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -158,6 +158,29 @@ def _find_free_port(host: str, start: int, max_attempts: int = 20) -> int: ) +_PID_FILE = Path.home() / ".unsloth" / "studio" / "studio.pid" + + +def _write_pid_file(): + """Write the current process PID to the studio PID file.""" + try: + _PID_FILE.parent.mkdir(parents = True, exist_ok = True) + _PID_FILE.write_text(str(os.getpid())) + except OSError: + pass + + +def _remove_pid_file(): + """Remove the PID file if it belongs to this process.""" + try: + if _PID_FILE.is_file(): + stored = _PID_FILE.read_text().strip() + if stored == str(os.getpid()): + _PID_FILE.unlink(missing_ok = True) + except OSError: + pass + + def _graceful_shutdown(server = None): """Explicitly shut down all subprocess backends and the uvicorn server. @@ -165,6 +188,7 @@ def _graceful_shutdown(server = None): before the parent exits. This is critical on Windows where atexit handlers are unreliable after Ctrl+C. """ + _remove_pid_file() logger.info("Graceful shutdown initiated — cleaning up subprocesses...") # 1. Shut down uvicorn server (releases the listening socket) @@ -307,6 +331,11 @@ def run_server( thread.start() time.sleep(3) + _write_pid_file() + import atexit + + atexit.register(_remove_pid_file) + if not silent: display_host = _resolve_external_ip() if host == "0.0.0.0" else host diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index c6d398eebd..a2f0873e22 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -166,6 +166,74 @@ def studio_default( typer.echo("\nShutting down...") +# ── unsloth studio stop ─────────────────────────────────────────────── + +_PID_FILE = STUDIO_HOME / "studio.pid" + + +@studio_app.command() +def stop(): + """Stop a running Unsloth Studio server. + + Reads the PID from ~/.unsloth/studio/studio.pid and sends SIGTERM + (or TerminateProcess on Windows) to shut it down gracefully. + """ + import signal as _signal + + if not _PID_FILE.is_file(): + typer.echo("No running Studio server found (no PID file).") + raise typer.Exit(0) + + pid_text = _PID_FILE.read_text().strip() + if not pid_text.isdigit(): + typer.echo(f"Invalid PID file contents: {pid_text}") + _PID_FILE.unlink(missing_ok = True) + raise typer.Exit(1) + + pid = int(pid_text) + + # Check if the process is still alive + try: + os.kill(pid, 0) + except ProcessLookupError: + typer.echo( + f"Studio server (PID {pid}) is not running. Cleaning up stale PID file." + ) + _PID_FILE.unlink(missing_ok = True) + raise typer.Exit(0) + except PermissionError: + pass # process exists but we may not own it; try to signal anyway + + # Send SIGTERM (graceful shutdown) or TerminateProcess on Windows + try: + if sys.platform == "win32": + subprocess.run(["taskkill", "/PID", str(pid), "/F"], check = True) + else: + os.kill(pid, _signal.SIGTERM) + typer.echo(f"Sent shutdown signal to Studio server (PID {pid}).") + except ProcessLookupError: + typer.echo(f"Studio server (PID {pid}) already exited.") + _PID_FILE.unlink(missing_ok = True) + raise typer.Exit(0) + except Exception as e: + typer.echo(f"Failed to stop Studio server (PID {pid}): {e}", err = True) + raise typer.Exit(1) + + # Wait briefly for the process to exit and clean up + for _ in range(10): + time.sleep(0.5) + try: + os.kill(pid, 0) + except ProcessLookupError: + _PID_FILE.unlink(missing_ok = True) + typer.echo("Studio server stopped.") + raise typer.Exit(0) + except PermissionError: + break + + typer.echo("Studio server is shutting down (may take a few seconds).") + + # ── unsloth studio setup / update ───────────────────────────────────── From 55d24d7c490addd9c6a8a1f0bd860de40aa0b890 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 08:32:38 -0700 Subject: [PATCH 22/94] feat(studio): editable context length with Apply/Reset for GGUF settings (#4592) * feat(studio): editable context length with Apply/Reset for GGUF model settings Previously the Context Length field was read-only and the backend hardcoded `-c 0`, ignoring custom values entirely. KV Cache Dtype also triggered an immediate model reload with no way to cancel. Backend: - llama_cpp.py: pass the actual n_ctx value to `-c` instead of always 0 - models/inference.py: relax max_seq_length to 0..1048576 (0 = model default) so GGUF models with large context windows are supported Frontend: - chat-runtime-store: add customContextLength and loadedKvCacheDtype state fields for dirty tracking - chat-settings-sheet: make Context Length an editable number input, stop KV Cache Dtype from auto-reloading, show Apply/Reset buttons when either setting has been changed - use-chat-model-runtime: send customContextLength as max_seq_length in the load request, reset after successful load * fix: preserve maxSeqLength for non-GGUF models in load request customContextLength ?? 0 sent max_seq_length=0 for non-GGUF models, breaking the finetuning/inference path that needs the slider value. Now uses a three-way branch: - customContextLength set: use it (user edited GGUF context) - GGUF without custom: 0 (model's native context) - Non-GGUF: maxSeqLength from the sampling slider * fix: keep max_seq_length default at 4096 for non-GGUF callers Only relax the bounds (ge=0 for GGUF's "model default" mode, le=1048576 for large context windows). The default stays at 4096 so API callers that omit max_seq_length still get a sane value for non-GGUF models. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): rename trust remote code toggle and hide when no model selected - Rename "Trust remote code" to "Enable custom code" - Shorten subtitle to "Only enable if sure" - Hide the toggle when no model is loaded (already hidden for GGUFs) * fix: restore ge=128 for max_seq_length validation Keep the minimum at 128 so the API rejects nonsensical values. GGUF path now sends the model's native context length (from ggufContextLength) instead of 0 when the user has not customized it. The upper bound stays at 1048576 for large-context GGUF models. * feat(studio): replace Context Length input with slider Use a ParamSlider (512 to model's native context, step 512) instead of a small number input. Shows "Max" when at the model's native context length. Consistent with the other slider controls in the settings panel. * feat(studio): add editable number input alongside Context Length slider The slider and number input stay synced -- dragging the slider updates the number, typing a number moves the slider. The input also accepts values beyond the slider range for power users who need custom context lengths larger than the model default. * fix(studio): widen context length input and use 1024 step for slider Make the number input wider (100px) so large values like 262144 are fully visible. Change slider step from 512 to 1024 and min from 512 to 1024. * fix(studio): context length number input increments by 1024 * fix(studio): cap context length input at model's native max Adds max attribute and clamps typed/incremented values so the context length cannot exceed the GGUF model's reported context window. * fix(studio): point "What's new" link to changelog page Changed from /blog to /docs/new/changelog. * fix(studio): preserve custom context length after Apply, remove stale subtitle - After a reload with a custom context length, keep the user's value in the UI instead of snapping back to the model's native max. ggufContextLength always reports the model's native metadata value regardless of what -c was passed, so we need to preserve customContextLength when it differs from native. - Remove "Reload to apply." from KV Cache Dtype subtitle since the Apply/Reset buttons now handle this. * feat(studio): auto-enable Search and Code tools when model supports them Previously toolsEnabled and codeToolsEnabled stayed false after loading a model even if it reported supports_tools=true. Now both toggles are automatically enabled when the loaded model supports tool calling, matching the existing behavior for reasoning. * fix(studio): auto-enable tools in autoLoadSmallestModel path The suggestion cards trigger autoLoadSmallestModel which bypasses selectModel entirely. It was hardcoding toolsEnabled: false and codeToolsEnabled: false even when the model supports tool calling. Now both are set from the load response, matching the selectModel behavior. Also sets kvCacheDtype/loadedKvCacheDtype for dirty tracking consistency. * fix(studio): re-read tool flags after auto-loading model The runtime state was captured once at the start of the chat adapter's run(), before autoLoadSmallestModel() executes. After auto-load enables tools in the store, the request was still built with the stale snapshot that had toolsEnabled=false. Now re-reads the store after auto-load so the first message includes tools. * fix(studio): re-read entire runtime state after auto-load, not just tools The runtime snapshot (including params.checkpoint, model id, and all tool/reasoning flags) was captured once before auto-load. After autoLoadSmallestModel sets the checkpoint and enables tools, the request was still built with stale params (empty checkpoint, tools disabled). Now re-reads the full store state after auto-load so the first message has the correct model, tools, and reasoning flags. * feat(studio): add Hugging Face token field in Preferences Adds a password input under Configuration > Preferences for users to enter their HF token. The token is persisted in localStorage and passed to all model validate/load/download calls, replacing the previously hardcoded null. This enables downloading gated and private models. * fix(studio): use model native context for GGUF auto-load, show friendly errors The auto-load paths and selectModel for GGUF were sending max_seq_length=4096 which now actually limits the context window (since we fixed the backend to respect n_ctx). Changed to send 0 for GGUF, which means "use model's native context size". Also replaced generic "An internal error occurred" messages with user-friendly descriptions for known errors like context size exceeded and lost connections. LoadRequest validation changed to ge=0 to allow the GGUF "model default" signal. The frontend slider still enforces min=128 for non-GGUF models. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(studio): filter out FP8 models from model search results Hide models matching *-FP8-* or *FP8-Dynamic* from both the recommended list and HF search results. These models are not yet supported in the inference UI. --------- Co-authored-by: Daniel Han Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 2 +- studio/backend/models/inference.py | 5 +- studio/backend/routes/inference.py | 31 ++++- .../assistant-ui/model-selector/pickers.tsx | 6 +- .../src/features/chat/api/chat-adapter.ts | 28 +++-- .../src/features/chat/chat-settings-sheet.tsx | 106 +++++++++++++++--- .../chat/hooks/use-chat-model-runtime.ts | 38 +++++-- .../src/features/chat/shared-composer.tsx | 2 +- .../chat/stores/chat-runtime-store.ts | 35 ++++++ .../src/features/chat/thread-sidebar.tsx | 2 +- 10 files changed, 208 insertions(+), 47 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 1d5643ac09..3f89cd3e5d 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -848,7 +848,7 @@ class LlamaCppBackend: "--port", str(self._port), "-c", - "0", # 0 = use model's native context size + str(n_ctx) if n_ctx > 0 else "0", # 0 = model's native context size "--parallel", "1", # Single-user studio, saves VRAM "--flash-attn", diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index b0498319ca..b4e496b051 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -22,7 +22,10 @@ class LoadRequest(BaseModel): None, description = "HuggingFace token for gated models" ) max_seq_length: int = Field( - 4096, ge = 128, le = 32768, description = "Maximum sequence length" + 0, + ge = 0, + le = 1048576, + description = "Maximum sequence length (0 = model default for GGUF)", ) load_in_4bit: bool = Field(True, description = "Load model in 4-bit quantization") is_lora: bool = Field(False, description = "Whether this is a LoRA adapter") diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index aa8c34a3c5..78d95fedbd 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -19,6 +19,27 @@ import asyncio import threading +import re as _re + + +def _friendly_error(exc: Exception) -> str: + """Extract a user-friendly message from known llama-server errors.""" + msg = str(exc) + m = _re.search( + r"request \((\d+) tokens?\) exceeds the available context size \((\d+) tokens?\)", + msg, + ) + if m: + return ( + f"Message too long: {m.group(1)} tokens exceeds the {m.group(2)}-token " + f"context window. Try increasing the Context Length in Model settings, " + f"or shorten the conversation." + ) + if "Lost connection to llama-server" in msg: + return "Lost connection to the model server. It may have crashed -- try reloading the model." + return "An internal error occurred" + + # Add backend directory to path backend_path = Path(__file__).parent.parent.parent if str(backend_path) not in sys.path: @@ -550,7 +571,7 @@ async def generate_stream( except Exception as e: backend.reset_generation_state() logger.error(f"Error during generation: {e}", exc_info = True) - yield f"data: {json.dumps({'error': 'An internal error occurred'})}\n\n" + yield f"data: {json.dumps({'error': _friendly_error(e)})}\n\n" return StreamingResponse( stream(), @@ -944,7 +965,7 @@ async def openai_chat_completions( logger.error( f"Error during audio input streaming: {e}", exc_info = True ) - yield f"data: {json.dumps({'error': {'message': 'An internal error occurred', 'type': 'server_error'}})}\n\n" + yield f"data: {json.dumps({'error': {'message': _friendly_error(e), 'type': 'server_error'}})}\n\n" return StreamingResponse( audio_input_stream(), @@ -1176,7 +1197,7 @@ async def openai_chat_completions( logger.error(f"Error during GGUF tool streaming: {e}\n{tb}") error_chunk = { "error": { - "message": "An internal error occurred", + "message": _friendly_error(e), "type": "server_error", }, } @@ -1314,7 +1335,7 @@ async def openai_chat_completions( logger.error(f"Error during GGUF streaming: {e}", exc_info = True) error_chunk = { "error": { - "message": "An internal error occurred", + "message": _friendly_error(e), "type": "server_error", }, } @@ -1495,7 +1516,7 @@ async def openai_chat_completions( logger.error(f"Error during OpenAI streaming: {e}", exc_info = True) error_chunk = { "error": { - "message": "An internal error occurred", + "message": _friendly_error(e), "type": "server_error", }, } diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 328cba3acd..3ca9aadb1f 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -454,7 +454,8 @@ export function HubModelPicker({ const recommendedIds = useMemo(() => { const all = dedupe([...models.map((model) => model.id), value ?? ""]) .filter((id) => !downloadedSet.has(id.toLowerCase())) - .filter((id) => !chatOnly || isGgufRepo(id)); + .filter((id) => !chatOnly || isGgufRepo(id)) + .filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id)); // Sort: GGUFs first, then hub models const gguf: string[] = []; const hub: string[] = []; @@ -498,7 +499,8 @@ export function HubModelPicker({ return results .map((result) => result.id) .filter((id) => !recommendedSet.has(id)) - .filter((id) => !chatOnly || isGgufRepo(id)); + .filter((id) => !chatOnly || isGgufRepo(id)) + .filter((id) => !/-FP8[-.]|FP8-Dynamic/i.test(id)); }, [recommendedSet, results, showHfSection, chatOnly]); const metricsById = useMemo( diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 15ac416b1f..95af560305 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -253,6 +253,7 @@ function waitForModelReady(abortSignal?: AbortSignal): Promise { * falls back to smallest cached safetensors model. */ async function autoLoadSmallestModel(): Promise { + const hfToken = useChatRuntimeStore.getState().hfToken || null; const toastId = toast("Loading a model…", { description: "Auto-selecting the smallest downloaded model.", duration: 5000, @@ -278,8 +279,8 @@ async function autoLoadSmallestModel(): Promise { const variant = downloaded[0]; const loadResp = await loadModel({ model_path: repo.repo_id, - hf_token: null, - max_seq_length: 4096, + hf_token: hfToken, + max_seq_length: 0, load_in_4bit: true, is_lora: false, gguf_variant: variant.quant, @@ -308,8 +309,10 @@ async function autoLoadSmallestModel(): Promise { supportsReasoning: loadResp.supports_reasoning ?? false, reasoningEnabled: loadResp.supports_reasoning ?? false, supportsTools: loadResp.supports_tools ?? false, - toolsEnabled: false, - codeToolsEnabled: false, + toolsEnabled: loadResp.supports_tools ?? false, + codeToolsEnabled: loadResp.supports_tools ?? false, + kvCacheDtype: loadResp.cache_type_kv ?? null, + loadedKvCacheDtype: loadResp.cache_type_kv ?? null, defaultChatTemplate: loadResp.chat_template ?? null, chatTemplateOverride: null, }); @@ -329,7 +332,7 @@ async function autoLoadSmallestModel(): Promise { try { const sfLoadResp = await loadModel({ model_path: repo.repo_id, - hf_token: null, + hf_token: hfToken, max_seq_length: 4096, load_in_4bit: true, is_lora: false, @@ -366,8 +369,8 @@ async function autoLoadSmallestModel(): Promise { try { const loadResp = await loadModel({ model_path: "unsloth/Qwen3.5-4B-GGUF", - hf_token: null, - max_seq_length: 4096, + hf_token: hfToken, + max_seq_length: 0, load_in_4bit: true, is_lora: false, gguf_variant: "UD-Q4_K_XL", @@ -391,7 +394,10 @@ async function autoLoadSmallestModel(): Promise { supportsReasoning: loadResp.supports_reasoning ?? false, reasoningEnabled: loadResp.supports_reasoning ?? false, supportsTools: loadResp.supports_tools ?? false, - toolsEnabled: false, + toolsEnabled: loadResp.supports_tools ?? false, + codeToolsEnabled: loadResp.supports_tools ?? false, + kvCacheDtype: loadResp.cache_type_kv ?? null, + loadedKvCacheDtype: loadResp.cache_type_kv ?? null, defaultChatTemplate: loadResp.chat_template ?? null, chatTemplateOverride: null, }); @@ -410,8 +416,7 @@ async function autoLoadSmallestModel(): Promise { export function createOpenAIStreamAdapter(): ChatModelAdapter { return { async *run({ messages, abortSignal, unstable_threadId }) { - const runtime = useChatRuntimeStore.getState(); - const { params } = runtime; + let runtime = useChatRuntimeStore.getState(); // Wait for in-progress model load to finish before inferring if (runtime.modelLoading) { @@ -430,6 +435,9 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { } } + // Re-read store after potential auto-load / model ready wait + runtime = useChatRuntimeStore.getState(); + const { params } = runtime; const { supportsTools, toolsEnabled, diff --git a/studio/frontend/src/features/chat/chat-settings-sheet.tsx b/studio/frontend/src/features/chat/chat-settings-sheet.tsx index 081e3efc26..a315aeb005 100644 --- a/studio/frontend/src/features/chat/chat-settings-sheet.tsx +++ b/studio/frontend/src/features/chat/chat-settings-sheet.tsx @@ -279,6 +279,14 @@ export function ChatSettingsPanel({ const ggufContextLength = useChatRuntimeStore((s) => s.ggufContextLength); const kvCacheDtype = useChatRuntimeStore((s) => s.kvCacheDtype); const setKvCacheDtype = useChatRuntimeStore((s) => s.setKvCacheDtype); + const loadedKvCacheDtype = useChatRuntimeStore((s) => s.loadedKvCacheDtype); + const customContextLength = useChatRuntimeStore((s) => s.customContextLength); + const setCustomContextLength = useChatRuntimeStore((s) => s.setCustomContextLength); + + const ctxDisplayValue = customContextLength ?? ggufContextLength ?? ""; + const kvDirty = kvCacheDtype !== loadedKvCacheDtype; + const ctxDirty = customContextLength !== null; + const modelSettingsDirty = kvDirty || ctxDirty; const [customPresets, setCustomPresets] = useState(() => loadSavedCustomPresets(), ); @@ -467,32 +475,53 @@ export function ChatSettingsPanel({
{isGguf && ( <> -
-
-
Context Length
-
- Reported by the loaded GGUF model. -
+
+
+ Context Length + { + const raw = e.target.value; + if (raw === "") { + setCustomContextLength(null); + return; + } + const v = parseInt(raw, 10); + if (!Number.isNaN(v) && v >= 0) { + const maxCtx = ggufContextLength ?? Infinity; + const clamped = Math.min(v, maxCtx); + setCustomContextLength(clamped === (ggufContextLength ?? 0) ? null : clamped); + } + }} + />
- { + setCustomContextLength(v === (ggufContextLength ?? 0) ? null : v); + }} />
KV Cache Dtype
- Quantize KV cache to reduce VRAM. Reload to apply. + Quantize KV cache to reduce VRAM.
+ {modelSettingsDirty && ( +
+ + +
+ )} )} - {!isGguf && ( + {!isGguf && params.checkpoint && (
-
Trust remote code
+
Enable custom code
- Allow models with custom code (e.g. Nemotron). Only enable for repos you trust. + Allow models with custom code (e.g. Nemotron). Only enable if sure.
+
@@ -775,6 +826,29 @@ function AutoHealToolCallsToggle() { ); } +function HfTokenField() { + const hfToken = useChatRuntimeStore((s) => s.hfToken); + const setHfToken = useChatRuntimeStore((s) => s.setHfToken); + + return ( +
+
+
Hugging Face Token
+
+ For downloading gated or private models. +
+
+ setHfToken(e.target.value)} + /> +
+ ); +} + function ChatTemplateSection({ onReloadModel, }: { diff --git a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts index cfdd4e774a..25c776948f 100644 --- a/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts +++ b/studio/frontend/src/features/chat/hooks/use-chat-model-runtime.ts @@ -354,12 +354,13 @@ export function useChatModelRuntime() { useChatRuntimeStore.getState().params.checkpoint; const paramsBeforeLoad = useChatRuntimeStore.getState().params; const maxSeqLength = paramsBeforeLoad.maxSeqLength; + const hfToken = useChatRuntimeStore.getState().hfToken || null; try { // Lightweight pre-flight validation: avoid unloading a working model // if the new identifier is clearly invalid (e.g. bad HF id / path). await validateModel({ model_path: modelId, - hf_token: null, + hf_token: hfToken, max_seq_length: maxSeqLength, load_in_4bit: true, is_lora: isLora, @@ -371,11 +372,16 @@ export function useChatModelRuntime() { previousWasUnloaded = true; } - const { chatTemplateOverride, kvCacheDtype } = useChatRuntimeStore.getState(); + const { chatTemplateOverride, kvCacheDtype, customContextLength, ggufContextLength } = useChatRuntimeStore.getState(); + // GGUF: use custom context length, or 0 = model's native context + // Non-GGUF: use the Max Seq Length slider value + const effectiveMaxSeqLength = customContextLength != null + ? customContextLength + : ggufVariant != null ? (ggufContextLength ?? 0) : maxSeqLength; const loadResponse = await loadModel({ model_path: modelId, - hf_token: null, - max_seq_length: maxSeqLength, + hf_token: hfToken, + max_seq_length: effectiveMaxSeqLength, load_in_4bit: true, is_lora: isLora, gguf_variant: ggufVariant ?? null, @@ -403,15 +409,27 @@ export function useChatModelRuntime() { } } } + const loadedKv = loadResponse.cache_type_kv ?? null; + const nativeCtx = loadResponse.is_gguf + ? (loadResponse.context_length ?? 131072) + : null; + // Keep customContextLength if the user set one and it differs + // from the model's native context; otherwise clear it so the + // display shows the native value without a dirty marker. + const keepCustomCtx = customContextLength != null + && customContextLength !== nativeCtx + ? customContextLength + : null; useChatRuntimeStore.setState({ - ggufContextLength: loadResponse.is_gguf - ? (loadResponse.context_length ?? 131072) - : null, + ggufContextLength: nativeCtx, supportsReasoning: loadResponse.supports_reasoning ?? false, reasoningEnabled: reasoningDefault, supportsTools: loadResponse.supports_tools ?? false, - toolsEnabled: false, - kvCacheDtype: loadResponse.cache_type_kv ?? null, + toolsEnabled: loadResponse.supports_tools ?? false, + codeToolsEnabled: loadResponse.supports_tools ?? false, + kvCacheDtype: loadedKv, + loadedKvCacheDtype: loadedKv, + customContextLength: keepCustomCtx, defaultChatTemplate: loadResponse.chat_template ?? null, chatTemplateOverride: null, }); @@ -432,7 +450,7 @@ export function useChatModelRuntime() { try { await loadModel({ model_path: previousCheckpoint, - hf_token: null, + hf_token: hfToken, max_seq_length: maxSeqLength, load_in_4bit: true, is_lora: previousIsLora, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index 78cfcc66d2..5ac8c79160 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -337,7 +337,7 @@ export function SharedComposer({ async function ensureModelLoaded(sel: CompareModelSelection): Promise { const resp = await loadModel({ model_path: sel.id, - hf_token: null, + hf_token: useChatRuntimeStore.getState().hfToken || null, max_seq_length: maxSeqLength, load_in_4bit: true, is_lora: sel.isLora, diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index 920737a279..2d60d52043 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -14,6 +14,7 @@ const AUTO_TITLE_KEY = "unsloth_chat_auto_title"; const AUTO_HEAL_TOOL_CALLS_KEY = "unsloth_auto_heal_tool_calls"; const MAX_TOOL_CALLS_KEY = "unsloth_max_tool_calls_per_message"; const TOOL_CALL_TIMEOUT_KEY = "unsloth_tool_call_timeout"; +const HF_TOKEN_KEY = "unsloth_hf_token"; const INFERENCE_PARAMS_KEY = "unsloth_chat_inference_params"; let hasShownInferencePersistenceWarning = false; @@ -62,6 +63,24 @@ function saveInt(key: string, value: number): void { } } +function loadString(key: string, fallback: string): string { + if (!canUseStorage()) return fallback; + try { + return localStorage.getItem(key) ?? fallback; + } catch { + return fallback; + } +} + +function saveString(key: string, value: string): void { + if (!canUseStorage()) return; + try { + localStorage.setItem(key, value); + } catch { + // ignore + } +} + function asFiniteNumber(value: unknown, fallback: number): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } @@ -127,6 +146,7 @@ type ChatRuntimeStore = { loras: ChatLoraSummary[]; runningByThreadId: Record; autoTitle: boolean; + hfToken: string; modelsError: string | null; activeGgufVariant: string | null; ggufContextLength: number | null; @@ -141,6 +161,8 @@ type ChatRuntimeStore = { maxToolCallsPerMessage: number; toolCallTimeout: number; kvCacheDtype: string | null; + loadedKvCacheDtype: string | null; + customContextLength: number | null; defaultChatTemplate: string | null; chatTemplateOverride: string | null; activeThreadId: string | null; @@ -159,6 +181,7 @@ type ChatRuntimeStore = { setLoras: (loras: ChatLoraSummary[]) => void; setThreadRunning: (threadId: string, running: boolean) => void; setAutoTitle: (enabled: boolean) => void; + setHfToken: (token: string) => void; setModelsError: (error: string | null) => void; setCheckpoint: (modelId: string, ggufVariant?: string | null) => void; setActiveThreadId: (threadId: string | null) => void; @@ -172,6 +195,7 @@ type ChatRuntimeStore = { setMaxToolCallsPerMessage: (value: number) => void; setToolCallTimeout: (value: number) => void; setKvCacheDtype: (dtype: string | null) => void; + setCustomContextLength: (v: number | null) => void; setChatTemplateOverride: (template: string | null) => void; setPendingAudio: (base64: string, name: string) => void; clearPendingAudio: () => void; @@ -184,6 +208,7 @@ export const useChatRuntimeStore = create((set) => ({ loras: [], runningByThreadId: {}, autoTitle: loadBool(AUTO_TITLE_KEY, false), + hfToken: loadString(HF_TOKEN_KEY, ""), modelsError: null, activeGgufVariant: null, ggufContextLength: null, @@ -198,6 +223,8 @@ export const useChatRuntimeStore = create((set) => ({ maxToolCallsPerMessage: loadInt(MAX_TOOL_CALLS_KEY, 10), toolCallTimeout: loadInt(TOOL_CALL_TIMEOUT_KEY, 5), kvCacheDtype: null, + loadedKvCacheDtype: null, + customContextLength: null, defaultChatTemplate: null, chatTemplateOverride: null, activeThreadId: null, @@ -235,6 +262,11 @@ export const useChatRuntimeStore = create((set) => ({ saveBool(AUTO_TITLE_KEY, autoTitle); return { autoTitle }; }), + setHfToken: (hfToken) => + set(() => { + saveString(HF_TOKEN_KEY, hfToken); + return { hfToken }; + }), setModelsError: (modelsError) => set({ modelsError }), setCheckpoint: (modelId, ggufVariant) => set((state) => ({ @@ -261,6 +293,8 @@ export const useChatRuntimeStore = create((set) => ({ codeToolsEnabled: false, toolStatus: null, kvCacheDtype: null, + loadedKvCacheDtype: null, + customContextLength: null, defaultChatTemplate: null, chatTemplateOverride: null, })), @@ -285,6 +319,7 @@ export const useChatRuntimeStore = create((set) => ({ return { toolCallTimeout }; }), setKvCacheDtype: (kvCacheDtype) => set({ kvCacheDtype }), + setCustomContextLength: (customContextLength) => set({ customContextLength }), setChatTemplateOverride: (chatTemplateOverride) => set({ chatTemplateOverride }), setPendingAudio: (base64, name) => set({ pendingAudioBase64: base64, pendingAudioName: name }), diff --git a/studio/frontend/src/features/chat/thread-sidebar.tsx b/studio/frontend/src/features/chat/thread-sidebar.tsx index 53c5521dc7..ba97d2ee6e 100644 --- a/studio/frontend/src/features/chat/thread-sidebar.tsx +++ b/studio/frontend/src/features/chat/thread-sidebar.tsx @@ -172,7 +172,7 @@ export function ThreadSidebar({ Learn more in docs Date: Wed, 25 Mar 2026 08:40:53 -0700 Subject: [PATCH 23/94] Bump installer min version to 2026.3.12 (#4600) --- install.ps1 | 6 +++--- install.sh | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/install.ps1 b/install.ps1 index bb0acf1237..86e4288e8e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -607,7 +607,7 @@ shell.Run cmd, 0, False # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state # in the new venv location, while preserving existing torch/CUDA Write-Host "==> Upgrading unsloth in migrated environment..." - uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.11" unsloth-zoo + uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.12" unsloth-zoo if ($StudioLocalInstall) { Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps @@ -622,7 +622,7 @@ shell.Run cmd, 0, False Write-Host "==> Installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.11" unsloth-zoo + uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.12" unsloth-zoo Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } else { @@ -632,7 +632,7 @@ shell.Run cmd, 0, False # Fallback: GPU detection failed to produce a URL -- let uv resolve torch Write-Host "==> Installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.11" --torch-backend=auto + uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.12" --torch-backend=auto Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } else { diff --git a/install.sh b/install.sh index 6f60c23d27..ebb5b1f9ae 100755 --- a/install.sh +++ b/install.sh @@ -767,7 +767,7 @@ if [ "$_MIGRATED" = true ]; then echo "==> Upgrading unsloth in migrated environment..." uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.3.11" unsloth-zoo + "unsloth>=2026.3.12" unsloth-zoo if [ "$STUDIO_LOCAL_INSTALL" = true ]; then echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps @@ -781,7 +781,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then echo "==> Installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.3.11" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.3.12" unsloth-zoo echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else @@ -792,7 +792,7 @@ else # Fallback: GPU detection failed to produce a URL -- let uv resolve torch echo "==> Installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.11" --torch-backend=auto + uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.12" --torch-backend=auto echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else From 23eb7fc0a7d01749101a4b7fe0c1468dc3345ea7 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 09:00:08 -0700 Subject: [PATCH 24/94] Fix Colab Studio launch and setup.ps1 box alignment (#4601) * Fix Colab Studio launch and setup.ps1 box alignment - colab.py: when the Studio venv is missing on Colab, pip-install backend dependencies (structlog, fastapi, etc.) from studio.txt into the current Python instead of failing with ModuleNotFoundError - setup.sh: on Colab without a venv, install backend deps into system Python and skip venv-dependent sections (Python stack update, llama.cpp build) that would otherwise fail - setup.ps1: use PadRight(47) for the done-line so "Setup Complete!" and "Update Complete!" both align with the box border * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/colab.py | 31 +++++++++++++++++++++++++++++++ studio/setup.ps1 | 3 ++- studio/setup.sh | 38 ++++++++++++++++++++++++++++++++------ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/studio/backend/colab.py b/studio/backend/colab.py index ecf9fc2907..ad9a7e98b2 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -18,6 +18,30 @@ if _backend_dir not in sys.path: import _platform_compat # noqa: F401 +def _is_colab() -> bool: + """Detect Google Colab by checking for COLAB_ prefixed env vars.""" + import os + + return any(k.startswith("COLAB_") for k in os.environ) + + +def _pip_install_backend_deps() -> None: + """Install Studio backend dependencies directly into the current Python. + + Used on Colab when the Studio venv does not exist (install.sh was not + run). Reads the requirements from studio.txt next to this file. + """ + import subprocess + + req_file = Path(__file__).parent / "requirements" / "studio.txt" + if not req_file.exists(): + return + print("Installing Studio backend dependencies ...") + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "-q", "-r", str(req_file)], + ) + + def _bootstrap_studio_venv() -> None: """Expose the Studio venv's site-packages to the current interpreter. @@ -25,9 +49,16 @@ def _bootstrap_studio_venv() -> None: installing the full stack into system Python, we prepend the venv's site-packages so that packages like structlog, fastapi, etc. are importable from notebook cells and take priority over system copies. + + If the venv does not exist and we are running on Colab, fall back to + pip-installing the backend dependencies into the current environment + so that imports like structlog and fastapi succeed. """ venv_lib = Path.home() / ".unsloth" / "studio" / "unsloth_studio" / "lib" if not venv_lib.exists(): + if _is_colab(): + _pip_install_backend_deps() + return import warnings warnings.warn( diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 0ac54d3866..42bae42819 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -1746,8 +1746,9 @@ if (-not $NeedLlamaSourceBuild) { # ============================================ Write-Host "" $doneLine = if ($env:SKIP_STUDIO_BASE -eq "1") { "Setup Complete!" } else { "Update Complete!" } +$doneContent = " $doneLine" Write-Host "+===============================================+" -ForegroundColor Green -Write-Host "| $doneLine |" -ForegroundColor Green +Write-Host ("|" + $doneContent.PadRight(47) + "|") -ForegroundColor Green Write-Host "| |" -ForegroundColor Green Write-Host "| Launch with: |" -ForegroundColor Green Write-Host "| unsloth studio -H 0.0.0.0 -p 8888 |" -ForegroundColor Green diff --git a/studio/setup.sh b/studio/setup.sh index a7991b83be..dd758e49e8 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -292,15 +292,23 @@ VENV_T5_DIR="$STUDIO_HOME/.venv_t5" [ -d "$REPO_ROOT/.venv_t5" ] && rm -rf "$REPO_ROOT/.venv_t5" # Note: do NOT delete $STUDIO_HOME/.venv here — install.sh handles migration +_COLAB_NO_VENV=false if [ ! -x "$VENV_DIR/bin/python" ]; then - echo "❌ ERROR: Virtual environment not found at $VENV_DIR" - echo " Run install.sh first to create the environment:" - echo " curl -fsSL https://unsloth.ai/install.sh | sh" - exit 1 + if [ "$IS_COLAB" = true ]; then + # On Colab there is no Studio venv -- install backend deps into system Python + echo " Colab detected, installing Studio backend dependencies..." + pip install -q -r "$SCRIPT_DIR/backend/requirements/studio.txt" 2>/dev/null || true + _COLAB_NO_VENV=true + else + echo "❌ ERROR: Virtual environment not found at $VENV_DIR" + echo " Run install.sh first to create the environment:" + echo " curl -fsSL https://unsloth.ai/install.sh | sh" + exit 1 + fi +else + source "$VENV_DIR/bin/activate" fi -source "$VENV_DIR/bin/activate" - install_python_stack() { python "$SCRIPT_DIR/install_python_stack.py" } @@ -324,6 +332,24 @@ fast_install() { cd "$SCRIPT_DIR" +# On Colab without a venv, skip all venv-dependent sections (Python deps +# update, llama.cpp build) -- the backend deps were already installed above. +if [ "$_COLAB_NO_VENV" = true ]; then + echo "✅ Studio backend dependencies installed into system Python" + + echo "" + echo "╔══════════════════════════════════════╗" + echo "║ Setup Complete! ║" + echo "╠══════════════════════════════════════╣" + echo "║ Unsloth Studio is ready to start ║" + echo "║ in your Colab notebook! ║" + echo "║ ║" + echo "║ from colab import start ║" + echo "║ start() ║" + echo "╚══════════════════════════════════════╝" + exit 0 +fi + # ── Check if Python deps need updating ── # Compare installed package version against PyPI latest. # Skip all Python dependency work if versions match (fast update path). From 9cb698c7749abac3050ba290226e148e1949e35d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 09:04:23 -0700 Subject: [PATCH 25/94] Update _utils.py --- unsloth/models/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 02e2170b70..1fffc62f81 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.3.12" +__version__ = "2026.3.13" __all__ = [ "SUPPORTS_BFLOAT16", From baabfa0a6e646762d3f8a957cc824d243eef05e5 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 09:38:02 -0700 Subject: [PATCH 26/94] Fix Colab huggingface-hub conflict, ensurepip fallback, bump to 2026.3.14 (#4603) * Fix Colab huggingface-hub conflict, ensurepip fallback, bump to 2026.3.14 - colab.py / setup.sh: relax == pins to >= when installing studio.txt on Colab so huggingface-hub does not clobber Colab's bundled version (breaks transformers is_offline_mode import) - install_python_stack.py: when uv is unavailable and pip is missing (uv-created venvs), bootstrap via ensurepip before attempting upgrade - Bump version to 2026.3.14 - Bump installer min version pins to 2026.3.14 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- install.ps1 | 6 +++--- install.sh | 6 +++--- studio/backend/colab.py | 30 +++++++++++++++++++++++++++++- studio/install_python_stack.py | 24 +++++++++++++++++++++--- studio/setup.sh | 9 +++++++-- unsloth/models/_utils.py | 2 +- 6 files changed, 64 insertions(+), 13 deletions(-) diff --git a/install.ps1 b/install.ps1 index 86e4288e8e..3ef251715e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -607,7 +607,7 @@ shell.Run cmd, 0, False # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state # in the new venv location, while preserving existing torch/CUDA Write-Host "==> Upgrading unsloth in migrated environment..." - uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.12" unsloth-zoo + uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo if ($StudioLocalInstall) { Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps @@ -622,7 +622,7 @@ shell.Run cmd, 0, False Write-Host "==> Installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.12" unsloth-zoo + uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } else { @@ -632,7 +632,7 @@ shell.Run cmd, 0, False # Fallback: GPU detection failed to produce a URL -- let uv resolve torch Write-Host "==> Installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.12" --torch-backend=auto + uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.14" --torch-backend=auto Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } else { diff --git a/install.sh b/install.sh index ebb5b1f9ae..a0f6ef2ee5 100755 --- a/install.sh +++ b/install.sh @@ -767,7 +767,7 @@ if [ "$_MIGRATED" = true ]; then echo "==> Upgrading unsloth in migrated environment..." uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.3.12" unsloth-zoo + "unsloth>=2026.3.14" unsloth-zoo if [ "$STUDIO_LOCAL_INSTALL" = true ]; then echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps @@ -781,7 +781,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then echo "==> Installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.3.12" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else @@ -792,7 +792,7 @@ else # Fallback: GPU detection failed to produce a URL -- let uv resolve torch echo "==> Installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.12" --torch-backend=auto + uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.14" --torch-backend=auto echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else diff --git a/studio/backend/colab.py b/studio/backend/colab.py index ad9a7e98b2..deddf28087 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -30,17 +30,45 @@ def _pip_install_backend_deps() -> None: Used on Colab when the Studio venv does not exist (install.sh was not run). Reads the requirements from studio.txt next to this file. + + Version constraints are stripped entirely so pip keeps whatever Colab + already has installed (e.g. huggingface-hub, datasets, transformers) + and only installs genuinely missing packages like structlog, fastapi. """ + import re import subprocess req_file = Path(__file__).parent / "requirements" / "studio.txt" if not req_file.exists(): return + + packages = [] + for line in req_file.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # Strip all version constraints -- just keep the package name + pkg_name = re.split(r"[><=!~;\[]", line)[0].strip() + if pkg_name: + packages.append(pkg_name) + + if not packages: + return print("Installing Studio backend dependencies ...") subprocess.check_call( - [sys.executable, "-m", "pip", "install", "-q", "-r", str(req_file)], + [sys.executable, "-m", "pip", "install", "-q"] + packages, ) + # Colab ships huggingface-hub 0.36.x which removed is_offline_mode, + # breaking transformers. Upgrade to 1.0+ which restored it. + try: + from huggingface_hub import is_offline_mode # noqa: F401 + except ImportError: + print("Upgrading huggingface-hub (is_offline_mode missing) ...") + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "-q", "huggingface-hub>=1.0"], + ) + def _bootstrap_studio_venv() -> None: """Expose the Studio venv's site-packages to the current interpreter. diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 39fec2e6f5..2c64695241 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -373,11 +373,29 @@ def install_python_stack() -> int: ], ) else: - run( - "Upgrading pip", - [sys.executable, "-m", "pip", "install", "--upgrade", "pip"], + # pip may not exist yet (uv-created venvs omit it). Try ensurepip + # first, then upgrade. Only fall back to a direct upgrade when pip + # is already present. + _has_pip = ( + subprocess.run( + [sys.executable, "-m", "pip", "--version"], + stdout = subprocess.DEVNULL, + stderr = subprocess.DEVNULL, + ).returncode + == 0 ) + if not _has_pip: + run( + "Bootstrapping pip via ensurepip", + [sys.executable, "-m", "ensurepip", "--upgrade"], + ) + else: + run( + "Upgrading pip", + [sys.executable, "-m", "pip", "install", "--upgrade", "pip"], + ) + # 3. Core packages: unsloth-zoo + unsloth (or custom package name) if skip_base: print(_green(f"✅ {package_name} already installed — skipping base packages")) diff --git a/studio/setup.sh b/studio/setup.sh index dd758e49e8..f103dd95a4 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -295,9 +295,14 @@ VENV_T5_DIR="$STUDIO_HOME/.venv_t5" _COLAB_NO_VENV=false if [ ! -x "$VENV_DIR/bin/python" ]; then if [ "$IS_COLAB" = true ]; then - # On Colab there is no Studio venv -- install backend deps into system Python + # On Colab there is no Studio venv -- install backend deps into system Python. + # Strip all version constraints so pip keeps Colab's pre-installed + # packages (huggingface-hub, datasets, transformers) and only pulls + # in genuinely missing ones (structlog, fastapi, etc.). echo " Colab detected, installing Studio backend dependencies..." - pip install -q -r "$SCRIPT_DIR/backend/requirements/studio.txt" 2>/dev/null || true + sed 's/[><=!~;].*//' "$SCRIPT_DIR/backend/requirements/studio.txt" \ + | grep -v '^#' | grep -v '^$' \ + | pip install -q -r /dev/stdin 2>/dev/null || true _COLAB_NO_VENV=true else echo "❌ ERROR: Virtual environment not found at $VENV_DIR" diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 1fffc62f81..da912dec76 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.3.13" +__version__ = "2026.3.14" __all__ = [ "SUPPORTS_BFLOAT16", From 55db24fc3187dcc51d74225e64b9fd4317961830 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 09:40:17 -0700 Subject: [PATCH 27/94] Update _utils.py --- unsloth/models/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index da912dec76..7540b62328 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.3.14" +__version__ = "2026.3.15" __all__ = [ "SUPPORTS_BFLOAT16", From c23c3a17e963daa33941537a3a5d1e132a7c40df Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:42:32 +0400 Subject: [PATCH 28/94] Update README.md (#4604) Update install instructions for studio --- README.md | 56 +++++++++++++++++++++++++------------------------------ 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 8f783bf661..963fbcebfc 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ irm https://unsloth.ai/install.ps1 | iex ``` Launch after setup via: ```powershell -& .\unsloth_studio\Scripts\unsloth.exe studio -H 0.0.0.0 -p 8888 +unsloth studio -H 0.0.0.0 -p 8888 ``` #### Docker @@ -84,60 +84,54 @@ docker run -d -e JUPYTER_PASSWORD="mypassword" \ #### macOS, Linux, WSL developer installs: ```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -uv venv unsloth_studio --python 3.13 -source unsloth_studio/bin/activate -uv pip install unsloth --torch-backend=auto -unsloth studio setup +git clone https://github.com/unslothai/unsloth +cd unsloth +./install.sh --local unsloth studio -H 0.0.0.0 -p 8888 ``` +Then to update : +```bash +unsloth studio update --local +``` #### Windows PowerShell developer installs: ```powershell -winget install -e --id Python.Python.3.13 -winget install --id=astral-sh.uv -e -uv venv unsloth_studio --python 3.13 -.\unsloth_studio\Scripts\activate -uv pip install unsloth --torch-backend=auto -unsloth studio setup +git clone https://github.com/unslothai/unsloth.git +cd unsloth +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\install.ps1 --local unsloth studio -H 0.0.0.0 -p 8888 ``` +Then to update : +```bash +unsloth studio update --local +``` #### Nightly - MacOS, Linux, WSL: ```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -git clone --filter=blob:none https://github.com/unslothai/unsloth.git unsloth_studio -cd unsloth_studio -uv venv --python 3.13 -source .venv/bin/activate -uv pip install -e . --torch-backend=auto -unsloth studio setup +git clone https://github.com/unslothai/unsloth +cd unsloth +git checkout nightly +./install.sh --local unsloth studio -H 0.0.0.0 -p 8888 ``` Then to launch every time: ```bash -cd unsloth_studio -source .venv/bin/activate unsloth studio -H 0.0.0.0 -p 8888 ``` #### Nightly - Windows: Run in Windows Powershell: ```bash -winget install -e --id Python.Python.3.13 -winget install --id=astral-sh.uv -e -git clone --filter=blob:none https://github.com/unslothai/unsloth.git unsloth_studio -cd unsloth_studio -uv venv --python 3.13 -.\.venv\Scripts\activate -uv pip install -e . --torch-backend=auto -unsloth studio setup +git clone https://github.com/unslothai/unsloth.git +cd unsloth +git checkout nightly +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\install.ps1 --local unsloth studio -H 0.0.0.0 -p 8888 ``` Then to launch every time: ```bash -cd unsloth_studio -.\.venv\Scripts\activate unsloth studio -H 0.0.0.0 -p 8888 ``` From 9fa67809e6f18ca0c418aee267f919c06fa566ea Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 25 Mar 2026 09:43:55 -0700 Subject: [PATCH 29/94] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 963fbcebfc..f253b2086b 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,6 @@ curl -fsSL https://unsloth.ai/install.sh | sh ``` If you don't have `curl`, use `wget`. Launch after setup via: ```bash -source unsloth_studio/bin/activate unsloth studio -H 0.0.0.0 -p 8888 ``` From c30e1d20291758fc20013adb5e6445d5e1899252 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:26:37 +0400 Subject: [PATCH 30/94] Update README.md remove newline from windows command --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f253b2086b..dd765849d9 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,7 @@ unsloth studio update --local ```powershell git clone https://github.com/unslothai/unsloth.git cd unsloth -Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -.\install.ps1 --local +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass .\install.ps1 --local unsloth studio -H 0.0.0.0 -p 8888 ``` Then to update : @@ -125,8 +124,7 @@ Run in Windows Powershell: git clone https://github.com/unslothai/unsloth.git cd unsloth git checkout nightly -Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -.\install.ps1 --local +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass .\install.ps1 --local unsloth studio -H 0.0.0.0 -p 8888 ``` Then to launch every time: From 88a6dfc5cd233d986a54ef7c34f1e765a5f05d6a Mon Sep 17 00:00:00 2001 From: Roland Tannous Date: Wed, 25 Mar 2026 19:54:12 +0000 Subject: [PATCH 31/94] Revert "Update README.md" This reverts commit c30e1d20291758fc20013adb5e6445d5e1899252. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dd765849d9..f253b2086b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,8 @@ unsloth studio update --local ```powershell git clone https://github.com/unslothai/unsloth.git cd unsloth -Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass .\install.ps1 --local +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\install.ps1 --local unsloth studio -H 0.0.0.0 -p 8888 ``` Then to update : @@ -124,7 +125,8 @@ Run in Windows Powershell: git clone https://github.com/unslothai/unsloth.git cd unsloth git checkout nightly -Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass .\install.ps1 --local +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +.\install.ps1 --local unsloth studio -H 0.0.0.0 -p 8888 ``` Then to launch every time: From d3049db42757587a50dccd275365b5247ac850d1 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:04:10 -0700 Subject: [PATCH 32/94] Update install instructions.md --- README.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f253b2086b..d9de9b5b93 100644 --- a/README.md +++ b/README.md @@ -57,18 +57,20 @@ Unsloth Studio (Beta) works on **Windows, Linux, WSL** and **macOS**. ```bash curl -fsSL https://unsloth.ai/install.sh | sh ``` +#### Windows: +```powershell +irm https://unsloth.ai/install.ps1 | iex +``` + +#### Launch If you don't have `curl`, use `wget`. Launch after setup via: ```bash unsloth studio -H 0.0.0.0 -p 8888 ``` -#### Windows: -```powershell -irm https://unsloth.ai/install.ps1 | iex -``` -Launch after setup via: -```powershell -unsloth studio -H 0.0.0.0 -p 8888 +#### Update +```bash +unsloth studio update ``` #### Docker @@ -81,7 +83,7 @@ docker run -d -e JUPYTER_PASSWORD="mypassword" \ unsloth/unsloth ``` -#### macOS, Linux, WSL developer installs: +#### Developer installs: macOS, Linux, WSL: ```bash git clone https://github.com/unslothai/unsloth cd unsloth @@ -93,7 +95,7 @@ Then to update : unsloth studio update --local ``` -#### Windows PowerShell developer installs: +#### Developer installs: Windows PowerShell: ```powershell git clone https://github.com/unslothai/unsloth.git cd unsloth @@ -106,7 +108,7 @@ Then to update : unsloth studio update --local ``` -#### Nightly - MacOS, Linux, WSL: +#### Nightly: MacOS, Linux, WSL: ```bash git clone https://github.com/unslothai/unsloth cd unsloth @@ -119,7 +121,7 @@ Then to launch every time: unsloth studio -H 0.0.0.0 -p 8888 ``` -#### Nightly - Windows: +#### Nightly: Windows: Run in Windows Powershell: ```bash git clone https://github.com/unslothai/unsloth.git @@ -134,6 +136,9 @@ Then to launch every time: unsloth studio -H 0.0.0.0 -p 8888 ``` +#### Uninstall +You can uninstall Unsloth Studio by deleting its folder. For example, run `rm -rf ~/.unsloth/studio`. Only use `rm -rf ~/.unsloth/` if you want to remove all Unsloth files, not just Studio. + ### Unsloth Core (code-based) #### Linux, WSL: ```bash From d4e9b708bb471efa065d0bdabd176d321e82f1e4 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:55:30 -0700 Subject: [PATCH 33/94] Update Install instructions.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index d9de9b5b93..c70b1b9c5f 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,6 @@ irm https://unsloth.ai/install.ps1 | iex ``` #### Launch -If you don't have `curl`, use `wget`. Launch after setup via: ```bash unsloth studio -H 0.0.0.0 -p 8888 ``` From 74ddef1402e79519ee0bbc8662095f4ebfa6fb49 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Thu, 26 Mar 2026 13:42:23 +0530 Subject: [PATCH 34/94] fix: skip flex_attention for models with non-zero attention_dropout (#4605) --- unsloth/models/_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 7540b62328..70d5420f84 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -234,6 +234,10 @@ def prefer_flex_attn_if_supported(model_class, config): model_class, "_supports_flex_attn", False ): return None + + attention_dropout = getattr(config, "attention_dropout", 0) or 0 + if attention_dropout > 0: + return None # GPT-OSS, Mllama and Gemma3N use eager/sdpa attention during # inference since flex attention returns incorrect results or errors out. # GPT-OSS: left padding issues cause incorrect outputs. From 6b3eb504b2a3f0ab2e9ec97436ef025284fadd18 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:55:46 +0400 Subject: [PATCH 35/94] Fix Colab setup skipping llama.cpp installation (#4618) * Fix Colab setup skipping llama.cpp installation The early exit 0 in the Colab no-venv path prevented setup.sh from ever reaching the llama.cpp install section. Remove the early exit and instead guard only the venv-dependent Python deps section, so execution continues through to the llama.cpp prebuilt/source install. * Simplify _SKIP_PYTHON_DEPS initialization * Add --local flag to setup.sh in Colab notebook --- studio/Unsloth_Studio_Colab.ipynb | 304 +++++++++++++++--------------- studio/setup.sh | 24 +-- 2 files changed, 158 insertions(+), 170 deletions(-) diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb index 7191dfde41..1479b1a05b 100644 --- a/studio/Unsloth_Studio_Colab.ipynb +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -1,157 +1,153 @@ { - "cells": [ - { - "cell_type": "markdown", - "metadata": { - "id": "view-in-github", - "colab_type": "text" - }, - "source": [ - "\"Open" - ] - }, - { - "cell_type": "markdown", - "id": "6b87de59", - "metadata": { - "id": "6b87de59" - }, - "source": [ - "To run this, press \"*Runtime*\" and press \"*Run all*\" on a **free** Tesla T4 Google Colab instance!\n", - "
\n", - "\n", - "\n", - " Join Discord if you need help + ⭐ Star us on Github ⭐\n", - "
\n", - "\n", - "To install Unsloth Studio on your local device, follow [our guide](https://unsloth.ai/docs/new/unsloth-studio/install). Unsloth Studio is licensed [AGPL-3.0](https://github.com/unslothai/unsloth/blob/main/studio/LICENSE.AGPL-3.0).\n", - "\n", - "### Unsloth Studio\n", - "\n", - "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). Currently, installation may take 30+ mins so use a newer GPU.\n", - "\n", - "\n", - "We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n", - "\n", - "[Features](https://unsloth.ai/docs/new/unsloth-studio#features) • [Quickstart](https://unsloth.ai/docs/new/unsloth-studio/start) • [Data Recipes](https://unsloth.ai/docs/new/unsloth-studio/data-recipe) • [Studio Chat](https://unsloth.ai/docs/new/unsloth-studio/chat) • [Export](https://unsloth.ai/docs/new/unsloth-studio/export)" - ] - }, - { - "cell_type": "markdown", - "id": "e4206349", - "metadata": { - "id": "e4206349" - }, - "source": [ - "

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

" + ] + }, + { + "cell_type": "markdown", + "id": "27da2957", + "metadata": { + "id": "27da2957" + }, + "source": [ + "### Setup: Clone repo and run setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27e68f91", + "metadata": { + "id": "27e68f91" + }, + "outputs": [], + "source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local" + }, + { + "cell_type": "markdown", + "id": "3e1771a9", + "metadata": { + "id": "3e1771a9" + }, + "source": [ + "### Start Unsloth Studio" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "277e431e", + "metadata": { + "id": "277e431e" + }, + "outputs": [], + "source": [ + "import sys, time\n", + "sys.path.insert(0, \"/content/unsloth/studio/backend\")\n", + "from colab import start\n", + "start()" + ] + }, + { + "cell_type": "code", + "source": [ + "from google.colab import output\n", + "output.serve_kernel_port_as_iframe(8888, height = 1200, width = \"100%\")\n", + "for _ in range(10000): time.sleep(300), print(\"=\", end = \"\")" + ], + "metadata": { + "id": "wb9UELh--XzX" + }, + "id": "wb9UELh--XzX", + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "f2b0c6a1", + "metadata": { + "id": "f2b0c6a1" + }, + "source": [ + "And we're done! If you have any questions on Unsloth, we have a [Discord](https://discord.gg/unsloth) channel! If you find any bugs or want to keep updated with the latest LLM stuff, or need help, join projects etc, feel free to join our Discord!\n", + "\n", + "Some other resources:\n", + "1. Looking to use Unsloth locally? Read our [Installation Guide](https://unsloth.ai/docs/get-started/install) for details on installing Unsloth on Windows, Docker, AMD, Intel GPUs.\n", + "2. Learn how to do Reinforcement Learning with our [RL Guide and notebooks](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide).\n", + "3. Read our guides and notebooks for [Text-to-speech (TTS)](https://unsloth.ai/docs/basics/text-to-speech-tts-fine-tuning) and [vision](https://unsloth.ai/docs/basics/vision-fine-tuning) model support.\n", + "4. Explore our [LLM Tutorials Directory](https://unsloth.ai/docs/models/tutorials-how-to-fine-tune-and-run-llms) to find dedicated guides for each model.\n", + "5. Need help with Inference? Read our [Inference & Deployment page](https://unsloth.ai/docs/basics/inference-and-deployment) for details on using vLLM, llama.cpp, Ollama etc.\n", + "\n", + "
\n", + " \n", + " \n", + " \n", + "\n", + " Join Discord if you need help + ⭐️ Star us on Github ⭐️\n", + "\n", + " This notebook is licensed AGPL-3.0\n", + "
" + ] + } + ], + "metadata": { + "accelerator": "GPU", + "colab": { + "gpuType": "T4", + "provenance": [], + "include_colab_link": true + }, + "kernelspec": { + "display_name": "Python 3", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 } \ No newline at end of file diff --git a/studio/setup.sh b/studio/setup.sh index f103dd95a4..4e46a12fe0 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -337,30 +337,22 @@ fast_install() { cd "$SCRIPT_DIR" -# On Colab without a venv, skip all venv-dependent sections (Python deps -# update, llama.cpp build) -- the backend deps were already installed above. +# On Colab without a venv, skip venv-dependent Python deps sections but +# continue to llama.cpp install so GGUF inference is available. if [ "$_COLAB_NO_VENV" = true ]; then echo "✅ Studio backend dependencies installed into system Python" - - echo "" - echo "╔══════════════════════════════════════╗" - echo "║ Setup Complete! ║" - echo "╠══════════════════════════════════════╣" - echo "║ Unsloth Studio is ready to start ║" - echo "║ in your Colab notebook! ║" - echo "║ ║" - echo "║ from colab import start ║" - echo "║ start() ║" - echo "╚══════════════════════════════════════╝" - exit 0 fi # ── Check if Python deps need updating ── # Compare installed package version against PyPI latest. # Skip all Python dependency work if versions match (fast update path). -_PKG_NAME="${STUDIO_PACKAGE_NAME:-unsloth}" +# On Colab (no venv), skip the entire venv-dependent Python deps section. _SKIP_PYTHON_DEPS=false -if [ "${SKIP_STUDIO_BASE:-0}" != "1" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then +if [ "$_COLAB_NO_VENV" = true ]; then + _SKIP_PYTHON_DEPS=true +fi +_PKG_NAME="${STUDIO_PACKAGE_NAME:-unsloth}" +if [ "$_SKIP_PYTHON_DEPS" != true ] && [ "${SKIP_STUDIO_BASE:-0}" != "1" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then # Only check when NOT called from install.sh (which just installed the package) INSTALLED_VER=$("$VENV_DIR/bin/python" -c " from importlib.metadata import version From 07abcb46de55d6fdbc2f5308be5a2a9a74d32d26 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Thu, 26 Mar 2026 11:40:11 +0100 Subject: [PATCH 36/94] fix: normalize search matching for recommended models and LoRA picker (#4615) Recommended models matching the query were filtered from HF results but the Recommended section was hidden during search, causing them to vanish entirely. - Show filtered recommended models during search by introducing `filteredRecommendedIds` - Switch `recommendedSet` to use filtered IDs when searching so dedup against HF results is correct - Hide empty "Hugging Face" label when recommended matches cover the query - Add `normalizeForSearch` helper to strip separators (spaces, hyphens, underscores, dots) so queries like "llama 3" match "Llama-3.2-1B" and "qwen 2.5" matches "Qwen2.5-7B" in both the recommended model filter and the LoRA adapter filter --- .../assistant-ui/model-selector/pickers.tsx | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 3ca9aadb1f..8d4b6ae0d6 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -47,6 +47,11 @@ function dedupe(values: string[]): string[] { return [...new Set(values.filter(Boolean))]; } +/** Normalize a string for fuzzy search: lowercase, strip separators. */ +function normalizeForSearch(s: string): string { + return s.toLowerCase().replace(/[\s\-_\.]/g, ""); +} + function ListLabel({ children }: { children: ReactNode }) { return (
@@ -492,7 +497,18 @@ export function HubModelPicker({ useRecommendedModelVram(recommendedIds); const showHfSection = debouncedQuery.trim().length > 0; - const recommendedSet = useMemo(() => new Set(visibleRecommendedIds), [visibleRecommendedIds]); + + // Recommended models that match the current search query + const filteredRecommendedIds = useMemo(() => { + if (!showHfSection) return []; + const q = normalizeForSearch(debouncedQuery.trim()); + return recommendedIds.filter((id) => normalizeForSearch(id).includes(q)); + }, [showHfSection, debouncedQuery, recommendedIds]); + + const recommendedSet = useMemo( + () => new Set(showHfSection ? filteredRecommendedIds : visibleRecommendedIds), + [showHfSection, filteredRecommendedIds, visibleRecommendedIds], + ); const hfIds = useMemo(() => { if (!showHfSection) return []; @@ -543,7 +559,8 @@ export function HubModelPicker({ string, { est: number; status: VramFitStatus | null; detail: string | null } >(); - for (const id of visibleRecommendedIds) { + const ids = showHfSection ? filteredRecommendedIds : visibleRecommendedIds; + for (const id of ids) { const totalParams = recommendedParamCountById.get(id); if (totalParams) { const est = estimateLoadingVram(totalParams, "qlora"); @@ -555,7 +572,7 @@ export function HubModelPicker({ } } return map; - }, [visibleRecommendedIds, recommendedParamCountById, gpu]); + }, [showHfSection, filteredRecommendedIds, visibleRecommendedIds, recommendedParamCountById, gpu]); const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length); @@ -712,13 +729,44 @@ export function HubModelPicker({ ) : null} + {showHfSection && filteredRecommendedIds.length > 0 ? ( + <> + {"\uD83E\uDDA5"} Recommended + {filteredRecommendedIds.map((id) => { + const vram = recommendedVramMap.get(id); + return ( +
+ handleModelClick(id)} + vramStatus={isGgufRepo(id) ? null : vram?.status ?? null} + vramEst={isGgufRepo(id) ? undefined : vram?.est} + gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} + /> + {expandedGguf === id && ( + + )} +
+ ); + })} + + ) : null} + {showHfSection ? ( <> - Hugging Face + {(hfIds.length > 0 || isLoading) && Hugging Face} {hfIds.length === 0 && !isLoading ? ( -
- No matching models. -
+ filteredRecommendedIds.length === 0 ? ( +
+ No matching models. +
+ ) : null ) : ( hfIds.map((id) => { const vram = vramMap.get(id); @@ -809,11 +857,11 @@ export function LoraModelPicker({ ); const grouped = useMemo(() => { - const needle = query.trim().toLowerCase(); + const needle = normalizeForSearch(query.trim()); const out = new Map(); for (const model of normalized) { - const searchText = `${model.name} ${model.baseModel} ${model.id}`.toLowerCase(); + const searchText = normalizeForSearch(`${model.name} ${model.baseModel} ${model.id}`); if (needle && !searchText.includes(needle)) continue; const key = model.baseModel || "Unknown base model"; From 352455610b41c32b3004f267034b76598f38218d Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:05:30 +0000 Subject: [PATCH 37/94] studio: align Dataset/Parameters/Training cards, fix expandable height, animate LoRA settings (#4614) * fix(studio): align config cards, dynamic height for expanders, LoRA collapsible * Fix clipping regressions in training, dataset, and params section cards - training-section: Add hasMessage conditional so the card expands (min-h) when startError, vision/audio incompatibility, or config validation messages are present instead of always using fixed height - dataset-section: Expand card when a local dataset is selected via upload (datasetSource === "upload" && selectedLocalDataset), not only when the Advanced panel is open - params-section: Guard loraOpen behind isLora so switching to full fine-tune collapses the card instead of staying expanded from stale React useState * Fix dataset card clipping for direct file uploads Use uploadedFile instead of selectedLocalDataset in the card height condition. selectedLocalDataset is derived from localDatasets.find() which only resolves for Data Recipe entries, not direct file uploads (.jsonl, .csv, .parquet, .arrow). The card already renders the Eval Dataset panel based on uploadedFile (line 750), so the height gate should match. --------- Co-authored-by: Daniel Han --- .../studio/sections/dataset-section.tsx | 15 +++++++---- .../studio/sections/model-section.tsx | 22 +++++++++------- .../studio/sections/params-section.tsx | 26 +++++++++---------- .../studio/sections/training-section.tsx | 5 ++-- .../src/features/studio/studio-page.tsx | 10 ++++--- .../hf-dataset-subset-split-selectors.tsx | 12 ++++----- studio/frontend/src/index.css | 8 ++++++ 7 files changed, 58 insertions(+), 40 deletions(-) diff --git a/studio/frontend/src/features/studio/sections/dataset-section.tsx b/studio/frontend/src/features/studio/sections/dataset-section.tsx index 7538fb1379..b12bdb09f0 100644 --- a/studio/frontend/src/features/studio/sections/dataset-section.tsx +++ b/studio/frontend/src/features/studio/sections/dataset-section.tsx @@ -410,16 +410,20 @@ export function DatasetSection() { }, [navigate]); return ( -
+
} title="Dataset" description="Select or upload training data" accent="indigo" - className="dark:shadow-border" + className={`dark:shadow-border ${ + advancedOpen || (datasetSource === "upload" && uploadedFile) + ? "min-h-studio-config-column" + : "h-studio-config-column" + }`} > -
-
+
+
Choose dataset @@ -453,6 +457,7 @@ export function DatasetSection() {
{ if (event.key !== "Enter") return; if (!(event.target instanceof HTMLInputElement)) return; @@ -521,7 +526,7 @@ export function DatasetSection() { ? "Search Hugging Face datasets..." : "Search local datasets..." } - className="w-full" + className="w-full min-w-0 overflow-hidden leading-5" showClear={true} > diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx index 9dccae3bc3..fa732bf62a 100644 --- a/studio/frontend/src/features/studio/sections/model-section.tsx +++ b/studio/frontend/src/features/studio/sections/model-section.tsx @@ -252,7 +252,7 @@ export function ModelSection() { ); return ( -
+
} title="Model" @@ -260,10 +260,10 @@ export function ModelSection() { accent="emerald" featured={true} badge="2x Faster Training" - className="shadow-border ring-1 ring-border" + className="shadow-border ring-border" > -
-
+
+
Local Model @@ -283,7 +283,7 @@ export function ModelSection() { -
+
-
+
Hugging Face Model @@ -405,6 +405,7 @@ export function ModelSection() {
{ if (event.key !== "Enter") return; if (!(event.target instanceof HTMLInputElement)) return; @@ -427,7 +428,10 @@ export function ModelSection() { itemToStringValue={(id) => id} autoHighlight={true} > - + @@ -513,7 +517,7 @@ export function ModelSection() {
-
+
Method @@ -581,7 +585,7 @@ export function ModelSection() {
-
+
Hugging Face Token (Optional) diff --git a/studio/frontend/src/features/studio/sections/params-section.tsx b/studio/frontend/src/features/studio/sections/params-section.tsx index b36929d9ea..6566303a81 100644 --- a/studio/frontend/src/features/studio/sections/params-section.tsx +++ b/studio/frontend/src/features/studio/sections/params-section.tsx @@ -160,13 +160,15 @@ export function ParamsSection(): ReactElement { const epochsSliderMax = Math.max(20, store.epochs, 1); return ( -
+
} title="Parameters" description="Configure training hyperparameters" accent="orange" - className="md:min-h-[470px]" + className={`${(isLora && loraOpen) || hyperOpen + ? "min-h-studio-config-column" + : "h-studio-config-column"} duration-150`} >
{/* Max Steps / Epochs */} @@ -380,21 +382,16 @@ export function ParamsSection(): ReactElement { {/* LoRA Settings */} {isLora && ( -
- -
+ + +
))}
-
-
+
+ + )} {/* Training Hyperparams */} diff --git a/studio/frontend/src/features/studio/sections/training-section.tsx b/studio/frontend/src/features/studio/sections/training-section.tsx index a47654d290..a43d3ea873 100644 --- a/studio/frontend/src/features/studio/sections/training-section.tsx +++ b/studio/frontend/src/features/studio/sections/training-section.tsx @@ -49,6 +49,7 @@ 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(null); const handleFileUpload = (e: React.ChangeEvent) => { @@ -98,13 +99,13 @@ export function TrainingSection() { }; return ( -
+
} title="Training" description="Monitor and control training" accent="blue" - className="md:min-h-[470px]" + className={hasMessage ? "min-h-studio-config-column" : "h-studio-config-column"} >
{/* Loss chart */} diff --git a/studio/frontend/src/features/studio/studio-page.tsx b/studio/frontend/src/features/studio/studio-page.tsx index a46e09cf7b..07a045685e 100644 --- a/studio/frontend/src/features/studio/studio-page.tsx +++ b/studio/frontend/src/features/studio/studio-page.tsx @@ -164,11 +164,13 @@ export function StudioPage(): ReactElement {
-
+
- - - +
+ + + +
diff --git a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx index ab8d8cf766..9467d62a7d 100644 --- a/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx +++ b/studio/frontend/src/features/training/components/hf-dataset-subset-split-selectors.tsx @@ -91,7 +91,7 @@ export function HfDatasetSubsetSplitSelectors({ <> {showPlaceholderDropdowns && ( <> -
+
@@ -145,7 +145,7 @@ export function HfDatasetSubsetSplitSelectors({ className={ variant === "wizard" ? "rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400" - : "rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400" + : "min-w-0 rounded-lg border border-amber-200 bg-amber-50 px-3.5 py-2.5 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400" } > {error} @@ -155,7 +155,7 @@ export function HfDatasetSubsetSplitSelectors({ {showDropdowns && ( <> {variant === "studio" ? ( -
+
+
{label} @@ -308,7 +308,7 @@ function SelectorDropdown({ onValueChange={(v) => onChange(v === "_none" ? null : v)} disabled={disabled} > - + diff --git a/studio/frontend/src/index.css b/studio/frontend/src/index.css index 307bf36819..8dc159a002 100644 --- a/studio/frontend/src/index.css +++ b/studio/frontend/src/index.css @@ -307,6 +307,14 @@ --tw-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); } + /* Fine-tuning Studio: equal default height, expandable when needed (md+) */ + .min-h-studio-config-column { + @apply md:min-h-[470px]; + } + .h-studio-config-column { + @apply md:h-[470px]; + } + [data-streamdown="unordered-list"] { list-style-type: disc; list-style-position: outside; From b3a3435ac3718c4c0f64dd9ddda3f89d0d8f6966 Mon Sep 17 00:00:00 2001 From: Etherll <61019402+Etherll@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:15:19 +0200 Subject: [PATCH 38/94] fix: Windows installer fails on _yaml.pyd Access Denied (os error 5) (#4617) * fix: avoid _yaml.pyd lock on Windows during dependency overrides * fix: move pytorch_tokenizers and kernels to no-deps install to avoid Windows _yaml.pyd loc --- studio/backend/requirements/extras-no-deps.txt | 2 ++ studio/backend/requirements/overrides.txt | 4 ---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt index 4b5aa86b5f..9934bacd24 100644 --- a/studio/backend/requirements/extras-no-deps.txt +++ b/studio/backend/requirements/extras-no-deps.txt @@ -12,3 +12,5 @@ git+https://github.com/meta-pytorch/OpenEnv.git torch-c-dlpack-ext sentence_transformers==5.2.0 transformers==4.57.6 +pytorch_tokenizers +kernels diff --git a/studio/backend/requirements/overrides.txt b/studio/backend/requirements/overrides.txt index 6852f601ed..176c651b96 100644 --- a/studio/backend/requirements/overrides.txt +++ b/studio/backend/requirements/overrides.txt @@ -1,6 +1,2 @@ # Torch AO overrides (installed with --force-reinstall --no-cache-dir) torchao==0.14.0 -pytorch_tokenizers - -# Kernel packages -kernels From 937da02f6c64eb4ecd19ba4fba0b12838e6a9bde Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 26 Mar 2026 05:45:30 -0700 Subject: [PATCH 39/94] Update Unsloth_Studio_Colab.ipynb --- studio/Unsloth_Studio_Colab.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb index 1479b1a05b..c3aec04820 100644 --- a/studio/Unsloth_Studio_Colab.ipynb +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -28,7 +28,7 @@ "\n", "### Unsloth Studio\n", "\n", - "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). Currently, installation may take 30+ mins so use a newer GPU.\n", + "Train and run open models with [**Unsloth Studio**](https://unsloth.ai/docs/new/unsloth-studio/start). NEW! Installation should now only take 2 mins!\n", "\n", "\n", "We are actively working on making Unsloth Studio install on Colab T4 GPUs faster.\n", @@ -150,4 +150,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} From a6fe743ebe8055142eedba8cbbf9f62fc676a571 Mon Sep 17 00:00:00 2001 From: Radouane Elhajali Date: Thu, 26 Mar 2026 14:55:54 +0100 Subject: [PATCH 40/94] studio: humanize ETA display for long training runs (#4608) * studio: humanize ETA display for long training runs When training takes hours or days, the ETA displayed raw minutes (e.g. '560m 50s'). This changes the format to: - Under 1 hour: Xm Ys (unchanged) - 1-24 hours: Xh Ym Zs - Over 24 hours: Xd Xh Xm * Fix formatDuration edge cases and consolidate duplicate for PR #4608 - Guard NaN/Infinity inputs with Number.isFinite() (matches formatNumber in same file) - Add sub-minute branch so 30s displays as "30s" instead of "0m 30s" - Accept undefined in type signature to match formatNumber pattern - Remove duplicate formatDuration from history-card-grid.tsx and import the shared one --------- Co-authored-by: Daniel Han --- .../src/features/studio/history-card-grid.tsx | 11 +---------- .../studio/sections/progress-section-lib.ts | 15 ++++++++++----- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/studio/frontend/src/features/studio/history-card-grid.tsx b/studio/frontend/src/features/studio/history-card-grid.tsx index 78859d2f81..9e72c9f75c 100644 --- a/studio/frontend/src/features/studio/history-card-grid.tsx +++ b/studio/frontend/src/features/studio/history-card-grid.tsx @@ -14,6 +14,7 @@ import { import { Button } from "@/components/ui/button"; import type { TrainingRunSummary } from "@/features/training"; import { deleteTrainingRun, listTrainingRuns } from "@/features/training"; +import { formatDuration } from "@/features/studio/sections/progress-section-lib"; import { cn } from "@/lib/utils"; import { Delete02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; @@ -129,16 +130,6 @@ function formatRelativeTime(isoDate: string): string { return `${days}d ago`; } -function formatDuration(seconds: number | null): string { - if (seconds == null) return "--"; - const total = Math.floor(seconds); - if (total < 60) return `${total}s`; - const min = Math.floor(total / 60); - const sec = total % 60; - if (min < 60) return `${min}m ${sec}s`; - const hrs = Math.floor(min / 60); - return `${hrs}h ${min % 60}m`; -} interface HistoryCardGridProps { onSelectRun: (runId: string) => void; diff --git a/studio/frontend/src/features/studio/sections/progress-section-lib.ts b/studio/frontend/src/features/studio/sections/progress-section-lib.ts index b0f484f0eb..d8bf87c288 100644 --- a/studio/frontend/src/features/studio/sections/progress-section-lib.ts +++ b/studio/frontend/src/features/studio/sections/progress-section-lib.ts @@ -35,12 +35,17 @@ export const phaseColors: Record = { stopped: "bg-muted text-muted-foreground", }; -export function formatDuration(seconds: number | null): string { - if (seconds == null || seconds < 0) return "--"; +export function formatDuration(seconds: number | null | undefined): string { + if (seconds == null || !Number.isFinite(seconds) || seconds < 0) return "--"; const total = Math.floor(seconds); - const min = Math.floor(total / 60); - const sec = total % 60; - return `${min}m ${sec}s`; + const d = Math.floor(total / 86400); + const h = Math.floor((total % 86400) / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + if (d > 0) return `${d}d ${h}h ${m}m`; + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; } export function formatNumber(value: number | null | undefined, digits: number): string { From 71781272dd6c374bb119dfae158dff7aa58c4254 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Thu, 26 Mar 2026 17:50:51 +0100 Subject: [PATCH 41/94] fix: add python-json-logger dependency to data-designer-deps (#4627) --- studio/backend/requirements/single-env/data-designer-deps.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/studio/backend/requirements/single-env/data-designer-deps.txt b/studio/backend/requirements/single-env/data-designer-deps.txt index 0cb42db01d..fc63230922 100644 --- a/studio/backend/requirements/single-env/data-designer-deps.txt +++ b/studio/backend/requirements/single-env/data-designer-deps.txt @@ -14,6 +14,7 @@ lxml<7,>=6.0.2 marko<3,>=2.1.2 mcp<2,>=1.26.0 networkx<4,>=3.0 +python-json-logger>=3,<4 ruff<1,>=0.14.10 scipy<2,>=1.11.0 sqlfluff<4,>=3.2.0 From e79a178200b454a2dc065e08e8f95b8a8d27589b Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:29:27 +0400 Subject: [PATCH 42/94] Allow install_python_stack to run on Colab (#4633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow install_python_stack to run on Colab The _COLAB_NO_VENV flag was setting _SKIP_PYTHON_DEPS=true, which skipped both the PyPI version check (needs $VENV_DIR/bin/python) and install_python_stack (uses sys.executable, works without a venv). Introduce a separate _SKIP_VERSION_CHECK flag for the version check, so install_python_stack still runs on Colab. The _SKIP_PYTHON_DEPS flag remains available for the "versions match" fast path. * Remove colab.py workarounds that broke transformers/hf-hub compatibility PR #4601 added _pip_install_backend_deps(), _bootstrap_studio_venv(), and _is_colab() to colab.py as workarounds for install_python_stack being skipped on Colab. These workarounds: - Stripped version constraints from studio.txt and installed into system Python - Upgraded huggingface-hub to >=1.0, breaking Colab's pre-installed transformers which requires huggingface-hub<1.0 With install_python_stack now running on Colab (previous commit), these workarounds are unnecessary — all deps are properly installed by setup.sh. Restore colab.py to its original PR #4237 structure: just get_colab_url(), show_link(), and start(). * Remove --local flag from setup.sh in Colab notebook The --local flag is not needed for the standard Colab flow since install_python_stack now runs on Colab and installs deps from PyPI. --- studio/Unsloth_Studio_Colab.ipynb | 2 +- studio/backend/colab.py | 84 ------------------------------- studio/setup.sh | 8 +-- 3 files changed, 6 insertions(+), 88 deletions(-) diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb index c3aec04820..46e2067ba7 100644 --- a/studio/Unsloth_Studio_Colab.ipynb +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -64,7 +64,7 @@ "id": "27e68f91" }, "outputs": [], - "source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh --local" + "source": "!git clone --depth 1 --branch main https://github.com/unslothai/unsloth.git\n%cd /content/unsloth\n!chmod +x studio/setup.sh && ./studio/setup.sh" }, { "cell_type": "markdown", diff --git a/studio/backend/colab.py b/studio/backend/colab.py index deddf28087..efd0e10bdb 100644 --- a/studio/backend/colab.py +++ b/studio/backend/colab.py @@ -18,90 +18,6 @@ if _backend_dir not in sys.path: import _platform_compat # noqa: F401 -def _is_colab() -> bool: - """Detect Google Colab by checking for COLAB_ prefixed env vars.""" - import os - - return any(k.startswith("COLAB_") for k in os.environ) - - -def _pip_install_backend_deps() -> None: - """Install Studio backend dependencies directly into the current Python. - - Used on Colab when the Studio venv does not exist (install.sh was not - run). Reads the requirements from studio.txt next to this file. - - Version constraints are stripped entirely so pip keeps whatever Colab - already has installed (e.g. huggingface-hub, datasets, transformers) - and only installs genuinely missing packages like structlog, fastapi. - """ - import re - import subprocess - - req_file = Path(__file__).parent / "requirements" / "studio.txt" - if not req_file.exists(): - return - - packages = [] - for line in req_file.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - # Strip all version constraints -- just keep the package name - pkg_name = re.split(r"[><=!~;\[]", line)[0].strip() - if pkg_name: - packages.append(pkg_name) - - if not packages: - return - print("Installing Studio backend dependencies ...") - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "-q"] + packages, - ) - - # Colab ships huggingface-hub 0.36.x which removed is_offline_mode, - # breaking transformers. Upgrade to 1.0+ which restored it. - try: - from huggingface_hub import is_offline_mode # noqa: F401 - except ImportError: - print("Upgrading huggingface-hub (is_offline_mode missing) ...") - subprocess.check_call( - [sys.executable, "-m", "pip", "install", "-q", "huggingface-hub>=1.0"], - ) - - -def _bootstrap_studio_venv() -> None: - """Expose the Studio venv's site-packages to the current interpreter. - - On Colab, notebook cells run outside the venv subshell. Instead of - installing the full stack into system Python, we prepend the venv's - site-packages so that packages like structlog, fastapi, etc. are - importable from notebook cells and take priority over system copies. - - If the venv does not exist and we are running on Colab, fall back to - pip-installing the backend dependencies into the current environment - so that imports like structlog and fastapi succeed. - """ - venv_lib = Path.home() / ".unsloth" / "studio" / "unsloth_studio" / "lib" - if not venv_lib.exists(): - if _is_colab(): - _pip_install_backend_deps() - return - import warnings - - warnings.warn( - f"Studio venv not found at {venv_lib.parent} -- run 'unsloth studio setup' first", - stacklevel = 2, - ) - return - for sp in venv_lib.glob("python*/site-packages"): - sp_str = str(sp) - if sp_str not in sys.path: - sys.path.insert(0, sp_str) - - -_bootstrap_studio_venv() - from loggers import get_logger logger = get_logger(__name__) diff --git a/studio/setup.sh b/studio/setup.sh index 4e46a12fe0..6f43446757 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -346,13 +346,15 @@ fi # ── Check if Python deps need updating ── # Compare installed package version against PyPI latest. # Skip all Python dependency work if versions match (fast update path). -# On Colab (no venv), skip the entire venv-dependent Python deps section. +# On Colab (no venv), skip this version check (it needs $VENV_DIR/bin/python) +# but still run install_python_stack below (it uses sys.executable). _SKIP_PYTHON_DEPS=false +_SKIP_VERSION_CHECK=false if [ "$_COLAB_NO_VENV" = true ]; then - _SKIP_PYTHON_DEPS=true + _SKIP_VERSION_CHECK=true fi _PKG_NAME="${STUDIO_PACKAGE_NAME:-unsloth}" -if [ "$_SKIP_PYTHON_DEPS" != true ] && [ "${SKIP_STUDIO_BASE:-0}" != "1" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then +if [ "$_SKIP_VERSION_CHECK" != true ] && [ "${SKIP_STUDIO_BASE:-0}" != "1" ] && [ "${STUDIO_LOCAL_INSTALL:-0}" != "1" ]; then # Only check when NOT called from install.sh (which just installed the package) INSTALLED_VER=$("$VENV_DIR/bin/python" -c " from importlib.metadata import version From e62085a3d6a77ffbc0ae796836dd55e36769195c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Thu, 26 Mar 2026 20:20:53 -0700 Subject: [PATCH 43/94] Fix repetition_penalty default causing 24% TPS drop in GGUF inference (#4634) The ChatCompletionRequest Pydantic model defaulted repetition_penalty to 1.1 when clients omitted the field. This silently forced llama-server to perform per-token repetition scanning, dropping streaming throughput from ~225 TPS to ~172 TPS (a 24% penalty). The Studio frontend always sends repetition_penalty=1.0 explicitly, so UI users were unaffected. But any API client hitting /v1/chat/completions without setting the field (curl, third-party integrations, Open WebUI, etc.) would get the slow path. Benchmarked on Qwen3.5-4B Q4_K_XL, GPU 0: - repeat_penalty=1.0: 225.2 TPS - repeat_penalty=1.1: 172.7 TPS (24% slower) - LM Studio (which applies rp internally): 170.8 TPS This aligns the Pydantic default with the frontend default (1.0), generate_chat_completion's function signature default (1.0), and llama-server's own default (1.0). --- studio/backend/models/inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index b4e496b051..36395af7bd 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -291,7 +291,7 @@ class ChatCompletionRequest(BaseModel): 0.01, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold" ) repetition_penalty: float = Field( - 1.1, ge = 1.0, le = 2.0, description = "[x-unsloth] Repetition penalty" + 1.0, ge = 1.0, le = 2.0, description = "[x-unsloth] Repetition penalty" ) image_base64: Optional[str] = Field( None, description = "[x-unsloth] Base64-encoded image for vision models" From d57a4d993d6810147ab0b9b89caafdd9549ba9cf Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 06:20:26 +0000 Subject: [PATCH 44/94] studio: fix chat CPU spike (#4632) Inline querier identity changed every render, forcing useLiveQuery to resubscribe continuously causing CPU spikes. Store querier in a ref and only re-subscribe when explicit deps change. --- studio/frontend/src/features/chat/db.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/studio/frontend/src/features/chat/db.ts b/studio/frontend/src/features/chat/db.ts index 9d530b96df..f1f83e4b2f 100644 --- a/studio/frontend/src/features/chat/db.ts +++ b/studio/frontend/src/features/chat/db.ts @@ -2,7 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import Dexie, { type EntityTable, liveQuery } from "dexie"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { MessageRecord, ThreadRecord } from "./types"; const db = new Dexie("unsloth-chat") as Dexie & { @@ -38,18 +38,29 @@ db.version(3) export { db }; +/** + * Wraps Dexie liveQuery for React state updates. + * + * Important: include every semantic query input in `deps` (filters, sort keys, + * IDs, etc). `querier` identity is intentionally ignored to avoid re-subscribing + * on every render when callers pass inline functions. + */ export function useLiveQuery( querier: () => Promise, deps: unknown[] = [], ): T | undefined { const [value, setValue] = useState(); + const querierRef = useRef(querier); + querierRef.current = querier; + useEffect(() => { - const sub = liveQuery(querier).subscribe({ + const sub = liveQuery(() => querierRef.current()).subscribe({ next: setValue, error: (err) => console.error("useLiveQuery:", err), }); return () => sub.unsubscribe(); + // Intentionally omit `querier` from deps: inline functions would re-subscribe every render. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [querier, ...deps]); + }, deps); return value; } From e9ac7853460df1805a97eab34d50976de77fa18d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 02:09:21 -0700 Subject: [PATCH 45/94] fix: install.sh Mac Intel compatibility + Studio no-torch support (#4624) * fix: install.sh Mac Intel compatibility + Studio no-torch support (#4621) On Intel Macs (x86_64), PyTorch has no wheels for torch >= 2.3, so the installer crashes. Even when torch is absent, Studio crashes on startup because two files have bare top-level torch imports. Studio's GGUF inference (llama.cpp) does not need PyTorch. Training and HF-inference already isolate torch to subprocesses. Only 2 files in the server startup chain had top-level torch imports preventing startup. Changes: - install.sh: detect architecture, default to Python 3.12 on Intel Mac, skip torch install, add Python 3.13.8 guard for arm64, pass UNSLOTH_NO_TORCH env var to setup.sh - data_collators.py: remove unused `import torch` (no torch.* refs) - chat_templates.py: lazy-import IterableDataset into function bodies - install_python_stack.py: add IS_MACOS/NO_TORCH constants, skip torch-dependent packages, skip overrides.txt, skip triton on macOS No existing working flow changes. Linux/WSL and macOS arm64 behavior is identical. * tests: add test suite for Mac Intel compat + no-torch mode Shell tests (test_mac_intel_compat.sh): - version_ge edge cases (9 tests) - Architecture detection for Darwin x86_64/arm64, Linux x86_64/aarch64 - get_torch_index_url returns cpu on simulated Darwin - UNSLOTH_NO_TORCH propagation to both setup.sh branches Python unit tests (test_no_torch_filtering.py): - _filter_requirements with NO_TORCH_SKIP_PACKAGES - NO_TORCH env var parsing (true/1/TRUE/false/0/unset) - IS_MACOS constant check - Overrides skip and triton macOS skip guards Python import tests (test_studio_import_no_torch.py): - data_collators.py loads in isolated no-torch venv - chat_templates.py has no top-level torch imports - Negative control confirms import torch fails without torch * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * tests: add E2E sandbox tests for Mac Intel no-torch mode Replace static/synthetic test stubs with real sandbox tests: - Shell: E2E uv venv creation at Python 3.12, mock uv shim to verify torch install is skipped when MAC_INTEL=true, dynamic env propagation test for UNSLOTH_NO_TORCH in both local and non-local install paths - Python filtering: test real extras.txt and extras-no-deps.txt with NO_TORCH_SKIP_PACKAGES, subprocess mock of install_python_stack() for 5 platform configs (NO_TORCH+macOS, Windows+NO_TORCH, normal Linux, Windows-only, macOS-only), VCS URL and env marker edge cases - Python imports: parametrized Python 3.12+3.13 venv fixture, dataclass instantiation for all 3 collator classes, chat_templates.py exec with stubs, negative controls proving import torch and torchao install fail in no-torch venvs 91 total tests, all passing. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: address reviewer findings for Intel Mac no-torch mode P1 fixes: - Auto-infer NO_TORCH in install_python_stack.py via platform.machine() so `unsloth studio update` preserves GGUF-only mode without needing the UNSLOTH_NO_TORCH env var (6/10 reviewers) - Add openai-whisper and transformers-cfg to NO_TORCH_SKIP_PACKAGES since both have unconditional torch dependencies (4/10 reviewers) - Skip unsloth-zoo on Intel Mac --local installs (depends on torch) in both migrated and fresh install paths (1/10) - Recreate stale 3.13 venvs as 3.12 on Intel Mac re-runs (1/10) - Detect Apple Silicon under Rosetta via sysctl hw.optional.arm64 and warn user to use native arm64 terminal (1/10) P2 fixes: - Wire new test files into tests/run_all.sh (4/10 reviewers) - Add update-path tests (skip_base=False) for Intel Mac - Add _infer_no_torch tests for platform auto-detection P3 fixes: - Fix macOS progress bar total (triton step skipped but was counted) - Fix temp file leak when Windows + NO_TORCH filters stack All tests pass: 30 shell, 66 Python (96 total). * feat: add --python override flag to install.sh Lets users force a specific Python version, e.g. ./install.sh --python 3.12. Addresses M2 Mac users whose systems resolve to a problematic 3.13.x patch. When --python is set, the Intel Mac stale-venv guard and 3.13.8 auto-downgrade are skipped so the user's choice is respected. * tests: add comprehensive E2E sandbox tests for no-torch mode Add test_e2e_no_torch_sandbox.py with 7 test groups (43 tests total) covering the full no-torch import chain, edge cases, and install logic: - Group 1: BEFORE vs AFTER import chain comparison (proves the bug existed and the fix works by synthetically prepending top-level torch imports) - Group 2: Dataclass instantiation without torch - Group 3: Edge cases with broken/fake torch modules on sys.path - Group 4: Hardware detection fallback to CPU without torch - Group 5: install.sh flag parsing, version resolution, arch detection - Group 6: install_python_stack.py NO_TORCH filtering - Group 7: Live server startup without torch (marked @server, skipped when studio venv is unavailable) All 43 tests pass on both Python 3.12 and 3.13 isolated venvs. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * feat: add --no-torch flag to install.sh/ps1, fix lazy import bug in dataset formatting - Fix chat_templates.py: narrow torch IterableDataset import into inner try/except ImportError so dataset.map() works without torch installed - Fix format_conversion.py: same lazy import fix for convert_chatml_to_alpaca and convert_alpaca_to_chatml - Add --no-torch flag to install.sh with unified SKIP_TORCH variable (driven by --no-torch flag OR MAC_INTEL auto-detection) - Add --no-torch flag to install.ps1 with $SkipTorch variable - Print CPU hint when no GPU detected and --no-torch not set - Replace MAC_INTEL guards with SKIP_TORCH in torch install sections - Update shell tests (40 pass) and Python tests (90 pass) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: address reviewer findings for --no-torch installer paths - Fix migrated-env branch in install.sh and install.ps1: check SKIP_TORCH first, then branch on STUDIO_LOCAL_INSTALL. Previously SKIP_TORCH+non-local fell into else and installed unsloth-zoo (which depends on torch), defeating --no-torch mode. - Fix $env:UNSLOTH_NO_TORCH leak in install.ps1: always set to "true" or "false" instead of only setting on the true branch. Prevents stale no-torch state from leaking across runs in the same PS session. - Fix install_python_stack.py update path: add NO_TORCH guard around base.txt install so unsloth studio update does not reinstall unsloth-zoo (which depends on torch) in no-torch mode. * fix: install unsloth + unsloth-zoo with --no-deps in no-torch mode Instead of skipping unsloth-zoo entirely (which breaks unsloth's dependency on it), install both packages with --no-deps so they are present but torch is not pulled in transitively. Applied consistently across all no-torch paths: migrated-env, fresh-local, fresh-non-local in install.sh, install.ps1, and install_python_stack.py. * chore: temporarily remove test files (will be added in a follow-up) * refactor: deduplicate SKIP_TORCH conditional branches in installers Collapse if/else blocks that differ only by --no-deps into a single branch with a conditional flag variable. Applied to migrated-env and fresh-local paths in install.sh, install.ps1, and install_python_stack.py. * fix: apply --no-deps to fresh non-local --no-torch install path The non-local else branch was missing $_no_deps_arg/$noDepsArg, so uv pip install unsloth would resolve torch from PyPI metadata (the published unsloth package still declares torch as a hard dep). Now --no-deps is applied consistently to all SKIP_TORCH code paths. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- install.ps1 | 40 ++++-- install.sh | 132 ++++++++++++++++-- .../backend/utils/datasets/chat_templates.py | 20 ++- .../backend/utils/datasets/data_collators.py | 1 - .../utils/datasets/format_conversion.py | 18 ++- studio/install_python_stack.py | 88 ++++++++++-- 6 files changed, 259 insertions(+), 40 deletions(-) diff --git a/install.ps1 b/install.ps1 index 3ef251715e..41e8efa5c3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,6 +1,7 @@ # Unsloth Studio Installer for Windows PowerShell # Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/install.ps1 | iex # Local: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\install.ps1 --local +# NoTorch: .\install.ps1 --no-torch (skip PyTorch, GGUF-only mode) # Test: .\install.ps1 --package roland-sloth function Install-UnslothStudio { @@ -10,11 +11,13 @@ function Install-UnslothStudio { $StudioLocalInstall = $false $PackageName = "unsloth" $RepoRoot = "" + $SkipTorch = $false $argList = $args for ($i = 0; $i -lt $argList.Count; $i++) { switch ($argList[$i]) { - "--local" { $StudioLocalInstall = $true } - "--package" { + "--local" { $StudioLocalInstall = $true } + "--no-torch" { $SkipTorch = $true } + "--package" { $i++ if ($i -ge $argList.Count) { Write-Host "[ERROR] --package requires an argument." -ForegroundColor Red @@ -585,6 +588,16 @@ shell.Run cmd, 0, False } $TorchIndexUrl = Get-TorchIndexUrl + # ── Print CPU-only hint when no GPU detected ── + if (-not $SkipTorch -and $TorchIndexUrl -like "*/cpu") { + Write-Host "" + Write-Host " NOTE: No NVIDIA GPU detected." -ForegroundColor Yellow + Write-Host " Installing CPU-only PyTorch. If you only need GGUF chat/inference," + Write-Host " re-run with --no-torch for a faster, lighter install:" + Write-Host " .\install.ps1 --no-torch" + Write-Host "" + } + # ── Install PyTorch first, then unsloth separately ── # # Why two steps? @@ -607,26 +620,32 @@ shell.Run cmd, 0, False # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state # in the new venv location, while preserving existing torch/CUDA Write-Host "==> Upgrading unsloth in migrated environment..." - uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo + $noDepsArg = if ($SkipTorch) { "--no-deps" } else { $null } + uv pip install --python $VenvPython $noDepsArg --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo if ($StudioLocalInstall) { Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } } elseif ($TorchIndexUrl) { - Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." - uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] Failed to install PyTorch (exit code $LASTEXITCODE)" -ForegroundColor Red - return + if ($SkipTorch) { + Write-Host "==> Skipping PyTorch (--no-torch flag set)." + } else { + Write-Host "==> Installing PyTorch ($TorchIndexUrl)..." + uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl + if ($LASTEXITCODE -ne 0) { + Write-Host "[ERROR] Failed to install PyTorch (exit code $LASTEXITCODE)" -ForegroundColor Red + return + } } Write-Host "==> Installing unsloth (this may take a few minutes)..." + $noDepsArg = if ($SkipTorch) { "--no-deps" } else { $null } if ($StudioLocalInstall) { - uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo + uv pip install --python $VenvPython $noDepsArg --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } else { - uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" + uv pip install --python $VenvPython $noDepsArg --upgrade-package unsloth "$PackageName" } } else { # Fallback: GPU detection failed to produce a URL -- let uv resolve torch @@ -659,6 +678,7 @@ shell.Run cmd, 0, False # Tell setup.ps1 to skip base package installation (install.ps1 already did it) $env:SKIP_STUDIO_BASE = "1" $env:STUDIO_PACKAGE_NAME = $PackageName + $env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" } if ($StudioLocalInstall) { $env:STUDIO_LOCAL_INSTALL = "1" $env:STUDIO_LOCAL_REPO = $RepoRoot diff --git a/install.sh b/install.sh index a0f6ef2ee5..2024622603 100755 --- a/install.sh +++ b/install.sh @@ -3,22 +3,34 @@ # Usage (curl): curl -fsSL https://unsloth.ai/install.sh | sh # Usage (wget): wget -qO- https://unsloth.ai/install.sh | sh # Usage (local): ./install.sh --local (install from local repo instead of PyPI) +# Usage (no-torch): ./install.sh --no-torch (skip PyTorch, GGUF-only mode) # Usage (test): ./install.sh --package roland-sloth (install a different package name) +# Usage (py): ./install.sh --python 3.12 (override auto-detected Python version) set -e # ── Parse flags ── STUDIO_LOCAL_INSTALL=false PACKAGE_NAME="unsloth" +_USER_PYTHON="" +_NO_TORCH_FLAG=false _next_is_package=false +_next_is_python=false for arg in "$@"; do if [ "$_next_is_package" = true ]; then PACKAGE_NAME="$arg" _next_is_package=false continue fi + if [ "$_next_is_python" = true ]; then + _USER_PYTHON="$arg" + _next_is_python=false + continue + fi case "$arg" in --local) STUDIO_LOCAL_INSTALL=true ;; --package) _next_is_package=true ;; + --python) _next_is_python=true ;; + --no-torch) _NO_TORCH_FLAG=true ;; esac done @@ -26,8 +38,12 @@ if [ "$_next_is_package" = true ]; then echo "❌ ERROR: --package requires an argument." >&2 exit 1 fi +if [ "$_next_is_python" = true ]; then + echo "❌ ERROR: --python requires a version argument (e.g. --python 3.12)." >&2 + exit 1 +fi -PYTHON_VERSION="3.13" +PYTHON_VERSION="" # resolved after platform detection STUDIO_HOME="$HOME/.unsloth/studio" VENV_DIR="$STUDIO_HOME/unsloth_studio" @@ -563,6 +579,47 @@ elif grep -qi microsoft /proc/version 2>/dev/null; then fi echo "==> Platform: $OS" +# ── Architecture detection & Python version ── +_ARCH=$(uname -m) +MAC_INTEL=false +if [ "$OS" = "macos" ] && [ "$_ARCH" = "x86_64" ]; then + # Guard against Apple Silicon running under Rosetta (reports x86_64). + # sysctl hw.optional.arm64 returns "1" on Apple Silicon even in Rosetta. + if [ "$(sysctl -in hw.optional.arm64 2>/dev/null || echo 0)" = "1" ]; then + echo "" + echo " WARNING: Apple Silicon detected, but this shell is running under Rosetta (x86_64)." + echo " Re-run install.sh from a native arm64 terminal for full PyTorch support." + echo " Continuing in GGUF-only mode for now." + echo "" + fi + MAC_INTEL=true +fi + +if [ -n "$_USER_PYTHON" ]; then + PYTHON_VERSION="$_USER_PYTHON" + echo " Using user-specified Python $PYTHON_VERSION (--python override)" +elif [ "$MAC_INTEL" = true ]; then + PYTHON_VERSION="3.12" +else + PYTHON_VERSION="3.13" +fi + +if [ "$MAC_INTEL" = true ]; then + echo "" + echo " NOTE: Intel Mac (x86_64) detected." + echo " PyTorch is unavailable for this platform (dropped Jan 2024)." + echo " Studio will install in GGUF-only mode." + echo " Chat, inference via GGUF, and data recipes will work." + echo " Training requires Apple Silicon or Linux with GPU." + echo "" +fi + +# ── Unified SKIP_TORCH: --no-torch flag OR Intel Mac auto-detection ── +SKIP_TORCH=false +if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then + SKIP_TORCH=true +fi + # ── Check system dependencies ── # cmake and git are needed by unsloth studio setup to build the GGUF inference # engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux. @@ -714,11 +771,38 @@ torch.testing.assert_close(torch.unique(E), torch.tensor((20,), device=E.device, fi fi +# If an Intel Mac has a stale 3.13 venv from a previous failed install, recreate +# (skip when the user explicitly chose a version via --python) +if [ "$SKIP_TORCH" = true ] && [ "$MAC_INTEL" = true ] && [ -z "$_USER_PYTHON" ] && [ -x "$VENV_DIR/bin/python" ]; then + _PY_MM=$("$VENV_DIR/bin/python" -c \ + "import sys; print('{}.{}'.format(*sys.version_info[:2]))" 2>/dev/null || echo "") + if [ "$_PY_MM" != "3.12" ]; then + echo " Recreating Intel Mac environment with Python 3.12 (was $_PY_MM)..." + rm -rf "$VENV_DIR" + fi +fi + if [ ! -x "$VENV_DIR/bin/python" ]; then echo "==> Creating Python ${PYTHON_VERSION} virtual environment (${VENV_DIR})..." uv venv "$VENV_DIR" --python "$PYTHON_VERSION" -else - echo "==> Using migrated environment at ${VENV_DIR}" +fi + +# Guard against Python 3.13.8 torch import bug on Apple Silicon +# (skip when the user explicitly chose a version via --python) +if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then + _PY_VER=$("$VENV_DIR/bin/python" -c \ + "import sys; print('{}.{}.{}'.format(*sys.version_info[:3]))" 2>/dev/null || echo "") + if [ "$_PY_VER" = "3.13.8" ]; then + echo " WARNING: Python 3.13.8 has a known torch import bug." + echo " Recreating venv with Python 3.12..." + rm -rf "$VENV_DIR" + PYTHON_VERSION="3.12" + uv venv "$VENV_DIR" --python "$PYTHON_VERSION" + fi +fi + +if [ -x "$VENV_DIR/bin/python" ]; then + echo "==> Using environment at ${VENV_DIR}" fi # ── Resolve repo root (for --local installs) ── @@ -759,13 +843,31 @@ get_torch_index_url() { } TORCH_INDEX_URL=$(get_torch_index_url) +# ── Print CPU-only hint when no GPU detected ── +case "$TORCH_INDEX_URL" in + */cpu) + if [ "$SKIP_TORCH" = false ] && [ "$OS" != "macos" ]; then + echo "" + echo " NOTE: No NVIDIA GPU detected (nvidia-smi not found)." + echo " Installing CPU-only PyTorch. If you only need GGUF chat/inference," + echo " re-run with --no-torch for a faster, lighter install:" + echo " curl -fsSL https://unsloth.ai/install.sh | sh -s -- --no-torch" + echo "" + fi + ;; +esac + # ── Install unsloth directly into the venv (no activation needed) ── _VENV_PY="$VENV_DIR/bin/python" if [ "$_MIGRATED" = true ]; then # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state # in the new venv location, while preserving existing torch/CUDA echo "==> Upgrading unsloth in migrated environment..." - uv pip install --python "$_VENV_PY" \ + _no_deps_arg="" + if [ "$SKIP_TORCH" = true ]; then + _no_deps_arg="--no-deps" + fi + uv pip install --python "$_VENV_PY" $_no_deps_arg \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ "unsloth>=2026.3.14" unsloth-zoo if [ "$STUDIO_LOCAL_INSTALL" = true ]; then @@ -773,19 +875,27 @@ if [ "$_MIGRATED" = true ]; then uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps fi elif [ -n "$TORCH_INDEX_URL" ]; then - # Fresh: Step 1 - install torch from explicit index - echo "==> Installing PyTorch ($TORCH_INDEX_URL)..." - uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \ - --index-url "$TORCH_INDEX_URL" + # Fresh: Step 1 - install torch from explicit index (skip when --no-torch or Intel Mac) + if [ "$SKIP_TORCH" = true ]; then + echo "==> Skipping PyTorch (--no-torch or Intel Mac x86_64)." + else + echo "==> Installing PyTorch ($TORCH_INDEX_URL)..." + uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" + fi # Fresh: Step 2 - install unsloth, preserving pre-installed torch echo "==> Installing unsloth (this may take a few minutes)..." + _no_deps_arg="" + if [ "$SKIP_TORCH" = true ]; then + _no_deps_arg="--no-deps" + fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - uv pip install --python "$_VENV_PY" \ + uv pip install --python "$_VENV_PY" $_no_deps_arg \ --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else - uv pip install --python "$_VENV_PY" \ + uv pip install --python "$_VENV_PY" $_no_deps_arg \ --upgrade-package unsloth "$PACKAGE_NAME" fi else @@ -837,10 +947,12 @@ if [ "$STUDIO_LOCAL_INSTALL" = true ]; then STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \ STUDIO_LOCAL_INSTALL=1 \ STUDIO_LOCAL_REPO="$_REPO_ROOT" \ + UNSLOTH_NO_TORCH="$SKIP_TORCH" \ bash "$SETUP_SH" bool: + """Determine whether to run in no-torch (GGUF-only) mode. + + Checks UNSLOTH_NO_TORCH env var first. When unset, falls back to + platform detection so that Intel Macs automatically use GGUF-only + mode even when invoked from ``unsloth studio update`` (which does + not inject the env var). + """ + env = os.environ.get("UNSLOTH_NO_TORCH") + if env is not None: + return env.strip().lower() in ("1", "true") + return IS_MAC_INTEL + + +NO_TORCH = _infer_no_torch() # -- Verbosity control ---------------------------------------------------------- # By default the installer shows a minimal progress bar (one line, in-place). @@ -161,6 +181,19 @@ def run( # Packages to skip on Windows (require special build steps) WINDOWS_SKIP_PACKAGES = {"open_spiel", "triton_kernels"} +# Packages to skip when torch is unavailable (Intel Mac GGUF-only mode). +# These packages either *are* torch extensions or have unconditional +# ``Requires-Dist: torch`` in their published metadata, so installing +# them would pull torch back into the environment. +NO_TORCH_SKIP_PACKAGES = { + "torch-stoi", + "timm", + "torchcodec", + "torch-c-dlpack-ext", + "openai-whisper", + "transformers-cfg", +} + # -- uv bootstrap ------------------------------------------------------ USE_UV = False # Set by _bootstrap_uv() at the start of install_python_stack() @@ -273,8 +306,13 @@ def pip_install( constraint_args = ["-c", str(CONSTRAINTS)] actual_req = req + temp_reqs: list[Path] = [] if req is not None and IS_WINDOWS and WINDOWS_SKIP_PACKAGES: actual_req = _filter_requirements(req, WINDOWS_SKIP_PACKAGES) + temp_reqs.append(actual_req) + if actual_req is not None and NO_TORCH and NO_TORCH_SKIP_PACKAGES: + actual_req = _filter_requirements(actual_req, NO_TORCH_SKIP_PACKAGES) + temp_reqs.append(actual_req) req_args: list[str] = [] if actual_req is not None: req_args = ["-r", str(actual_req)] @@ -298,8 +336,8 @@ def pip_install( pip_cmd = _build_pip_cmd(args) + constraint_args + req_args run(f"{label} (pip)" if USE_UV else label, pip_cmd) finally: - if actual_req is not None and actual_req != req: - actual_req.unlink(missing_ok = True) + for temp_req in temp_reqs: + temp_req.unlink(missing_ok = True) def download_file(url: str, dest: Path) -> None: @@ -352,6 +390,8 @@ def install_python_stack() -> int: # When --local is used, overlay a local repo checkout after updating deps local_repo = os.environ.get("STUDIO_LOCAL_REPO", "") base_total = 10 if IS_WINDOWS else 11 + if IS_MACOS: + base_total -= 1 # triton step is skipped on macOS _TOTAL = (base_total - 1) if skip_base else base_total # 1. Try to use uv for faster installs (must happen before pip upgrade @@ -399,6 +439,28 @@ def install_python_stack() -> int: # 3. Core packages: unsloth-zoo + unsloth (or custom package name) if skip_base: print(_green(f"✅ {package_name} already installed — skipping base packages")) + elif NO_TORCH: + # No-torch mode: install unsloth + unsloth-zoo without torch deps + _progress("base packages (no torch)") + pip_install( + "Updating base packages (no-torch mode)", + "--no-cache-dir", + "--no-deps", + "--upgrade-package", + "unsloth", + "--upgrade-package", + "unsloth-zoo", + req = REQ_ROOT / "base.txt", + ) + if local_repo: + pip_install( + "Overlaying local repo (editable)", + "--no-cache-dir", + "--no-deps", + "-e", + local_repo, + constrain = False, + ) elif local_repo: # Local dev install: update deps from base.txt, then overlay the # local checkout as an editable install (--no-deps so torch is @@ -462,16 +524,22 @@ def install_python_stack() -> int: ) # 4. Overrides (torchao, transformers) -- force-reinstall - _progress("dependency overrides") - pip_install( - "Installing dependency overrides", - "--force-reinstall", - "--no-cache-dir", - req = REQ_ROOT / "overrides.txt", - ) + # Skip entirely when torch is unavailable (e.g. Intel Mac GGUF-only mode) + # because overrides.txt contains torchao which requires torch. + if NO_TORCH: + _progress("dependency overrides (skipped, no torch)") + else: + _progress("dependency overrides") + pip_install( + "Installing dependency overrides", + "--force-reinstall", + "--no-cache-dir", + req = REQ_ROOT / "overrides.txt", + ) # 5. Triton kernels (no-deps, from source) - if not IS_WINDOWS: + # Skip on Windows (no support) and macOS (no support). + if not IS_WINDOWS and not IS_MACOS: _progress("triton kernels") pip_install( "Installing triton kernels", From 2ffc8d2cea1435d357c1062c9229bb33b89cea26 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 02:33:45 -0700 Subject: [PATCH 46/94] tests: add no-torch / Intel Mac test suite (#4646) * tests: add no-torch / Intel Mac test suite Add comprehensive test coverage for the no-torch / --no-torch installer and Studio backend changes introduced in #4624. Shell tests (tests/sh/test_mac_intel_compat.sh): - version_ge edge cases (9 tests) - Architecture detection + Python version resolution (4 tests) - get_torch_index_url on Darwin (2 tests) - UNSLOTH_NO_TORCH propagation via SKIP_TORCH (5 tests) - E2E uv venv creation at Python 3.12 (3 tests) - E2E torch skip with mock uv shim (4 tests) - UNSLOTH_NO_TORCH env propagation (4 tests) - --python override flag parsing + resolution (11 tests) - --no-torch flag parsing (4 tests) - SKIP_TORCH unification (3 tests) - CPU hint printing (2 tests) Python tests (tests/python/test_no_torch_filtering.py): - _filter_requirements unit tests with synthetic + real requirements files - NO_TORCH / IS_MACOS constant parsing - Subprocess mock of install_python_stack() across platform configs - install.sh --no-torch flag structural + subprocess tests Python tests (tests/python/test_studio_import_no_torch.py): - AST checks for data_collators.py, chat_templates.py, format_conversion.py - Parametrized venv tests (Python 3.12 + 3.13) for no-torch exec - Dataclass instantiation without torch - format_conversion convert functions without torch - Negative controls (import torch fails, torchao fails) Python tests (tests/python/test_e2e_no_torch_sandbox.py): - Before/after import chain tests - Edge cases (broken torch, fake torch, lazy import) - Hardware detection without torch - install.sh logic tests (flag parsing, version resolution) - install_python_stack filtering tests - Live server startup tests (opt-in via @server marker) * fix: address review comments on test suite - Fix always-true assertion in test_studio_import_no_torch.py (or True) - Make IS_MACOS test platform-aware instead of hardcoding Linux - Restore torchvision + torchaudio in server test cleanup (not just torch) - Include server stderr in skip message for easier debugging * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/python/conftest.py | 7 + tests/python/test_e2e_no_torch_sandbox.py | 1239 +++++++++++++++++++ tests/python/test_no_torch_filtering.py | 753 +++++++++++ tests/python/test_studio_import_no_torch.py | 582 +++++++++ tests/run_all.sh | 3 + tests/sh/test_mac_intel_compat.sh | 582 +++++++++ 6 files changed, 3166 insertions(+) create mode 100644 tests/python/conftest.py create mode 100644 tests/python/test_e2e_no_torch_sandbox.py create mode 100644 tests/python/test_no_torch_filtering.py create mode 100644 tests/python/test_studio_import_no_torch.py create mode 100644 tests/sh/test_mac_intel_compat.sh diff --git a/tests/python/conftest.py b/tests/python/conftest.py new file mode 100644 index 0000000000..66542d2451 --- /dev/null +++ b/tests/python/conftest.py @@ -0,0 +1,7 @@ +"""Shared pytest configuration for tests/python/.""" + + +def pytest_configure(config): + config.addinivalue_line( + "markers", "server: heavyweight tests requiring studio venv" + ) diff --git a/tests/python/test_e2e_no_torch_sandbox.py b/tests/python/test_e2e_no_torch_sandbox.py new file mode 100644 index 0000000000..f36f69201d --- /dev/null +++ b/tests/python/test_e2e_no_torch_sandbox.py @@ -0,0 +1,1239 @@ +"""Comprehensive E2E sandbox tests for PR #4624 (fix/install-mac-intel-no-torch). + +Proves that: +- The BEFORE state (top-level torch imports) crashes without torch +- The AFTER state (lazy/removed imports) works without torch +- Edge cases (broken torch, partial torch) are handled gracefully +- Hardware detection falls back to CPU without torch +- install.sh flag parsing and platform detection work correctly +- install_python_stack.py NO_TORCH filtering is correct +- Live server starts and responds without torch (optional, requires studio venv) + +Run: + # Lightweight tests (Groups 1-6, ~26 tests): + python -m pytest tests/python/test_e2e_no_torch_sandbox.py -v -k "not server" + + # Server tests (Group 7, 4 tests, requires studio venv): + python -m pytest tests/python/test_e2e_no_torch_sandbox.py -v -m server +""" + +from __future__ import annotations + +import os +import shutil +import signal +import subprocess +import sys +import textwrap +import time +from pathlib import Path +from unittest import mock + +import pytest + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parents[2] +STUDIO_DIR = REPO_ROOT / "studio" +BACKEND_DIR = STUDIO_DIR / "backend" +DATASETS_DIR = BACKEND_DIR / "utils" / "datasets" +HARDWARE_DIR = BACKEND_DIR / "utils" / "hardware" +INSTALL_SH = REPO_ROOT / "install.sh" +INSTALL_PY = STUDIO_DIR / "install_python_stack.py" + +DATA_COLLATORS = DATASETS_DIR / "data_collators.py" +CHAT_TEMPLATES = DATASETS_DIR / "chat_templates.py" +FORMAT_DETECTION = DATASETS_DIR / "format_detection.py" +MODEL_MAPPINGS = DATASETS_DIR / "model_mappings.py" +VLM_PROCESSING = DATASETS_DIR / "vlm_processing.py" +HARDWARE_PY = HARDWARE_DIR / "hardware.py" + +# Studio venv for server tests +STUDIO_VENV = Path.home() / ".unsloth" / "studio" / "unsloth_studio" + +# Add studio to path for install_python_stack imports +sys.path.insert(0, str(STUDIO_DIR)) + + +# --------------------------------------------------------------------------- +# Cross-platform helpers +# --------------------------------------------------------------------------- + + +def _venv_python(venv_dir: Path) -> Path: + """Return the Python executable path for a venv, cross-platform.""" + if sys.platform == "win32": + return venv_dir / "Scripts" / "python.exe" + return venv_dir / "bin" / "python" + + +def _has_uv() -> bool: + return shutil.which("uv") is not None + + +def _create_no_torch_venv(venv_dir: Path, python_version: str = "3.12") -> Path | None: + """Create a uv venv with no torch. Returns python path or None.""" + result = subprocess.run( + ["uv", "venv", str(venv_dir), "--python", python_version], + capture_output = True, + ) + if result.returncode != 0: + return None + py = _venv_python(venv_dir) + if not py.exists(): + return None + # Verify torch is NOT importable + check = subprocess.run([str(py), "-c", "import torch"], capture_output = True) + if check.returncode == 0: + return None + return py + + +def _run_in_sandbox( + py: str | Path, + code: str, + timeout: int = 60, + env: dict | None = None, +) -> subprocess.CompletedProcess: + """Run Python code in a sandboxed interpreter.""" + return subprocess.run( + [str(py), "-c", code], + capture_output = True, + timeout = timeout, + env = env, + ) + + +def _run_sh(script: str, timeout: int = 30) -> subprocess.CompletedProcess: + """Run a bash snippet and return the result.""" + return subprocess.run( + ["bash", "-c", script], + capture_output = True, + timeout = timeout, + ) + + +# --------------------------------------------------------------------------- +# Stub generators +# --------------------------------------------------------------------------- + + +def _write_loggers_stub(sandbox: Path) -> None: + """Create a minimal loggers package stub (replaces structlog-backed real one).""" + loggers_dir = sandbox / "loggers" + loggers_dir.mkdir(exist_ok = True) + (loggers_dir / "__init__.py").write_text( + "from .handlers import get_logger\n__all__ = ['get_logger']\n", + encoding = "utf-8", + ) + (loggers_dir / "handlers.py").write_text( + textwrap.dedent("""\ + class _Logger: + def info(self, msg, *a, **k): pass + def warning(self, msg, *a, **k): pass + def debug(self, msg, *a, **k): pass + def error(self, msg, *a, **k): pass + def msg(self, msg, *a, **k): pass + def get_logger(name=None): + return _Logger() + """), + encoding = "utf-8", + ) + + +def _write_structlog_stub(sandbox: Path) -> None: + """Create a minimal structlog stub.""" + structlog_dir = sandbox / "structlog" + structlog_dir.mkdir(exist_ok = True) + (structlog_dir / "__init__.py").write_text( + textwrap.dedent("""\ + class _Logger: + def info(self, msg, *a, **k): pass + def warning(self, msg, *a, **k): pass + def debug(self, msg, *a, **k): pass + def error(self, msg, *a, **k): pass + def msg(self, msg, *a, **k): pass + def get_logger(name=None): + return _Logger() + """), + encoding = "utf-8", + ) + + +def _write_hardware_stub(sandbox: Path) -> None: + """Create utils/hardware stub with dataset_map_num_proc.""" + hw_dir = sandbox / "utils" / "hardware" + hw_dir.mkdir(parents = True, exist_ok = True) + (sandbox / "utils" / "__init__.py").write_text("", encoding = "utf-8") + (hw_dir / "__init__.py").write_text( + "def dataset_map_num_proc(n=None): return n\n", + encoding = "utf-8", + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope = "session") +def repo_root(): + return REPO_ROOT + + +@pytest.fixture +def sandbox_dir(tmp_path): + """Per-test temporary sandbox directory.""" + return tmp_path + + +@pytest.fixture(params = ["3.12", "3.13"], scope = "module") +def no_torch_venv(request, tmp_path_factory): + """Create a temporary uv venv with no torch. + + Parametrized for 3.12 (Intel Mac default) and 3.13 (Apple Silicon/Linux). + """ + if not _has_uv(): + pytest.skip("uv not available") + + py_version = request.param + venv_dir = tmp_path_factory.mktemp(f"e2e_no_torch_{py_version}") + py = _create_no_torch_venv(venv_dir, py_version) + if py is None: + pytest.skip(f"Could not create Python {py_version} no-torch venv") + return str(py) + + +# =========================================================================== +# Group 1: BEFORE vs AFTER -- Import Chain (6 tests) +# =========================================================================== + + +class TestBeforeAfterImportChain: + """Prove the bug exists in BEFORE state and is fixed in AFTER state. + + BEFORE = PR branch files with top-level torch import synthetically prepended + (simulates the main branch). + AFTER = PR branch files as-is (lazy imports / torch import removed). + """ + + # -- BEFORE: crashes -- + + def test_before_chat_templates_crashes(self, no_torch_venv, sandbox_dir): + """BEFORE: chat_templates.py with top-level 'from torch.utils.data import + IterableDataset' crashes without torch.""" + source = CHAT_TEMPLATES.read_text(encoding = "utf-8") + before_source = "from torch.utils.data import IterableDataset\n" + source + + before_file = sandbox_dir / "chat_templates_before.py" + before_file.write_text(before_source, encoding = "utf-8") + + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None}})() + sys.modules['loggers'] = loggers + fd = types.ModuleType('format_detection') + fd.detect_dataset_format = fd.detect_multimodal_dataset = fd.detect_custom_format_heuristic = lambda *a, **k: None + sys.modules['format_detection'] = fd + mm = types.ModuleType('model_mappings') + mm.MODEL_TO_TEMPLATE_MAPPER = {{}} + sys.modules['model_mappings'] = mm + source = open({str(before_file)!r}).read() + source = source.replace('from .format_detection import', 'from format_detection import') + source = source.replace('from .model_mappings import', 'from model_mappings import') + exec(source) + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode != 0 + ), "BEFORE chat_templates.py should crash without torch" + assert ( + b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr + ) + + def test_before_data_collators_crashes(self, no_torch_venv, sandbox_dir): + """BEFORE: data_collators.py with top-level 'import torch' crashes.""" + source = DATA_COLLATORS.read_text(encoding = "utf-8") + before_source = "import torch\n" + source + + before_file = sandbox_dir / "data_collators_before.py" + before_file.write_text(before_source, encoding = "utf-8") + + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(before_file)!r}).read()) + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode != 0 + ), "BEFORE data_collators.py should crash without torch" + assert ( + b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr + ) + + def test_before_full_import_chain_crashes(self, no_torch_venv, sandbox_dir): + """BEFORE: full utils/datasets/ package with top-level torch imports crashes.""" + _write_loggers_stub(sandbox_dir) + _write_hardware_stub(sandbox_dir) + + pkg_dir = sandbox_dir / "utils" / "datasets" + pkg_dir.mkdir(parents = True, exist_ok = True) + + # Copy torch-free modules as-is + shutil.copy2(FORMAT_DETECTION, pkg_dir / "format_detection.py") + shutil.copy2(MODEL_MAPPINGS, pkg_dir / "model_mappings.py") + shutil.copy2(VLM_PROCESSING, pkg_dir / "vlm_processing.py") + + # BEFORE data_collators: prepend top-level 'import torch' + dc_source = DATA_COLLATORS.read_text(encoding = "utf-8") + (pkg_dir / "data_collators.py").write_text( + "import torch\n" + dc_source, + encoding = "utf-8", + ) + + # BEFORE chat_templates: prepend top-level IterableDataset import + ct_source = CHAT_TEMPLATES.read_text(encoding = "utf-8") + (pkg_dir / "chat_templates.py").write_text( + "from torch.utils.data import IterableDataset\n" + ct_source, + encoding = "utf-8", + ) + + # Minimal __init__.py that triggers the chain + (pkg_dir / "__init__.py").write_text( + textwrap.dedent("""\ + from .format_detection import detect_dataset_format + from .data_collators import DataCollatorSpeechSeq2SeqWithPadding + from .chat_templates import DEFAULT_ALPACA_TEMPLATE + """), + encoding = "utf-8", + ) + + code = textwrap.dedent(f"""\ + import sys + sys.path.insert(0, {str(sandbox_dir)!r}) + from utils.datasets import detect_dataset_format + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode != 0 + ), "BEFORE full import chain should crash without torch" + assert ( + b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr + ) + + # -- AFTER: succeeds -- + + def test_after_chat_templates_imports(self, no_torch_venv): + """AFTER: PR branch chat_templates.py imports fine without torch.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None}})() + sys.modules['loggers'] = loggers + fd = types.ModuleType('format_detection') + fd.detect_dataset_format = fd.detect_multimodal_dataset = fd.detect_custom_format_heuristic = lambda *a, **k: None + sys.modules['format_detection'] = fd + mm = types.ModuleType('model_mappings') + mm.MODEL_TO_TEMPLATE_MAPPER = {{}} + sys.modules['model_mappings'] = mm + source = open({str(CHAT_TEMPLATES)!r}).read() + source = source.replace('from .format_detection import', 'from format_detection import') + source = source.replace('from .model_mappings import', 'from model_mappings import') + exec(source) + print("OK") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode == 0 + ), f"AFTER chat_templates.py should work without torch:\n{result.stderr.decode()}" + assert b"OK" in result.stdout + + def test_after_data_collators_imports(self, no_torch_venv): + """AFTER: PR branch data_collators.py imports fine without torch.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(DATA_COLLATORS)!r}).read()) + print("OK") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode == 0 + ), f"AFTER data_collators.py should work without torch:\n{result.stderr.decode()}" + assert b"OK" in result.stdout + + def test_after_full_import_chain_imports(self, no_torch_venv, sandbox_dir): + """AFTER: full utils/datasets/ package imports fine without torch.""" + _write_loggers_stub(sandbox_dir) + _write_hardware_stub(sandbox_dir) + + pkg_dir = sandbox_dir / "utils" / "datasets" + pkg_dir.mkdir(parents = True, exist_ok = True) + + # Copy AFTER versions (PR branch -- no top-level torch) + for src in [ + FORMAT_DETECTION, + MODEL_MAPPINGS, + VLM_PROCESSING, + DATA_COLLATORS, + CHAT_TEMPLATES, + ]: + if src.exists(): + shutil.copy2(src, pkg_dir / src.name) + + # Minimal __init__.py + (pkg_dir / "__init__.py").write_text( + textwrap.dedent("""\ + from .format_detection import detect_dataset_format, detect_custom_format_heuristic + from .model_mappings import MODEL_TO_TEMPLATE_MAPPER + from .chat_templates import DEFAULT_ALPACA_TEMPLATE, get_dataset_info_summary + from .data_collators import ( + DataCollatorSpeechSeq2SeqWithPadding, + DeepSeekOCRDataCollator, + VLMDataCollator, + ) + from .vlm_processing import generate_smart_vlm_instruction + """), + encoding = "utf-8", + ) + + code = textwrap.dedent(f"""\ + import sys + sys.path.insert(0, {str(sandbox_dir)!r}) + from utils.datasets import ( + detect_dataset_format, + DEFAULT_ALPACA_TEMPLATE, + DataCollatorSpeechSeq2SeqWithPadding, + DeepSeekOCRDataCollator, + VLMDataCollator, + generate_smart_vlm_instruction, + ) + assert 'Instruction' in DEFAULT_ALPACA_TEMPLATE + print("OK: full import chain succeeded") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode == 0 + ), f"AFTER full import chain should work:\n{result.stderr.decode()}" + assert b"OK: full import chain succeeded" in result.stdout + + +# =========================================================================== +# Group 2: Dataclass Instantiation (4 tests) +# =========================================================================== + + +class TestDataclassInstantiation: + """Verify dataclass collators can be instantiated and constants accessed + without torch in an isolated venv.""" + + def test_speech_collator_instantiate(self, no_torch_venv): + """DataCollatorSpeechSeq2SeqWithPadding(processor=None) succeeds.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(DATA_COLLATORS)!r}).read()) + obj = DataCollatorSpeechSeq2SeqWithPadding(processor=None) + assert obj.processor is None + print("OK") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}" + + def test_deepseek_ocr_collator_instantiate(self, no_torch_venv): + """DeepSeekOCRDataCollator has correct default field values.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(DATA_COLLATORS)!r}).read()) + obj = DeepSeekOCRDataCollator(processor=None) + assert obj.processor is None + assert obj.max_length == 2048 + assert obj.ignore_index == -100 + print("OK") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}" + + def test_vlm_collator_instantiate(self, no_torch_venv): + """VLMDataCollator has correct default field values.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(DATA_COLLATORS)!r}).read()) + obj = VLMDataCollator(processor=None) + assert obj.processor is None + assert obj.max_length == 2048 + assert obj.mask_input_tokens is True + print("OK") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}" + + def test_alpaca_template_accessible(self, no_torch_venv): + """DEFAULT_ALPACA_TEMPLATE constant is accessible and contains 'Instruction'.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None}})() + sys.modules['loggers'] = loggers + fd = types.ModuleType('format_detection') + fd.detect_dataset_format = fd.detect_multimodal_dataset = fd.detect_custom_format_heuristic = lambda *a, **k: None + sys.modules['format_detection'] = fd + mm = types.ModuleType('model_mappings') + mm.MODEL_TO_TEMPLATE_MAPPER = {{}} + sys.modules['model_mappings'] = mm + ns = {{}} + source = open({str(CHAT_TEMPLATES)!r}).read() + source = source.replace('from .format_detection import', 'from format_detection import') + source = source.replace('from .model_mappings import', 'from model_mappings import') + exec(source, ns) + assert 'Instruction' in ns['DEFAULT_ALPACA_TEMPLATE'] + print("OK") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}" + + +# =========================================================================== +# Group 3: Edge Cases -- Partial/Broken Torch (4 tests) +# =========================================================================== + + +class TestEdgeCasesBrokenTorch: + """Test behavior with fake or broken torch modules on sys.path.""" + + def test_fake_broken_torch_module(self, no_torch_venv, sandbox_dir): + """A fake torch that raises RuntimeError('CUDA not found') on import. + + data_collators.py (no top-level torch import) should still load fine. + """ + torch_dir = sandbox_dir / "torch" + torch_dir.mkdir() + (torch_dir / "__init__.py").write_text( + 'raise RuntimeError("CUDA not found")\n', + encoding = "utf-8", + ) + _write_loggers_stub(sandbox_dir) + shutil.copy2(DATA_COLLATORS, sandbox_dir / "data_collators.py") + + code = textwrap.dedent(f"""\ + import sys + sys.path.insert(0, {str(sandbox_dir)!r}) + exec(open({str(sandbox_dir / 'data_collators.py')!r}).read()) + obj = DataCollatorSpeechSeq2SeqWithPadding(processor=None) + print("OK: data_collators works despite broken torch on sys.path") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode == 0 + ), f"Should work with broken torch:\n{result.stderr.decode()}" + assert b"OK:" in result.stdout + + def test_torch_import_error_hardware_fallback(self, no_torch_venv, sandbox_dir): + """A fake torch that raises ImportError. detect_hardware() falls back to CPU.""" + torch_dir = sandbox_dir / "torch" + torch_dir.mkdir() + (torch_dir / "__init__.py").write_text( + 'raise ImportError("No torch binary")\n', + encoding = "utf-8", + ) + _write_loggers_stub(sandbox_dir) + _write_structlog_stub(sandbox_dir) + + code = textwrap.dedent(f"""\ + import sys + sys.path.insert(0, {str(sandbox_dir)!r}) + source = open({str(HARDWARE_PY)!r}).read() + ns = {{'__name__': '__test__'}} + exec(source, ns) + result = ns['detect_hardware']() + assert result == ns['DeviceType'].CPU, f"Expected CPU, got {{result}}" + print("OK: detect_hardware returned CPU") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode == 0 + ), f"detect_hardware should fallback to CPU:\n{result.stderr.decode()}" + assert b"OK: detect_hardware returned CPU" in result.stdout + + def test_fake_torch_no_cuda(self, no_torch_venv, sandbox_dir): + """Fake torch that imports OK but torch.cuda.is_available() returns False. + + detect_hardware() should still fall back to CPU. + """ + torch_dir = sandbox_dir / "torch" + torch_dir.mkdir() + (torch_dir / "__init__.py").write_text( + textwrap.dedent("""\ + class _Cuda: + @staticmethod + def is_available(): + return False + cuda = _Cuda() + class version: + cuda = None + """), + encoding = "utf-8", + ) + _write_loggers_stub(sandbox_dir) + _write_structlog_stub(sandbox_dir) + + code = textwrap.dedent(f"""\ + import sys + sys.path.insert(0, {str(sandbox_dir)!r}) + source = open({str(HARDWARE_PY)!r}).read() + ns = {{'__name__': '__test__'}} + exec(source, ns) + result = ns['detect_hardware']() + assert result == ns['DeviceType'].CPU, f"Expected CPU, got {{result}}" + print("OK: detect_hardware returned CPU with fake torch (no CUDA)") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode == 0 + ), f"Should fall back to CPU:\n{result.stderr.decode()}" + assert b"OK:" in result.stdout + + def test_lazy_torch_fails_at_call_time_not_import_time( + self, no_torch_venv, sandbox_dir + ): + """apply_chat_template_to_dataset is importable without torch. + + Calling the alpaca branch triggers the lazy 'from torch.utils.data' inside + the try block. This should fail at call time, not import time -- proving the + lazy import pattern works correctly. + """ + _write_loggers_stub(sandbox_dir) + + code = textwrap.dedent(f"""\ + import sys, types + sys.path.insert(0, {str(sandbox_dir)!r}) + fd = types.ModuleType('format_detection') + fd.detect_dataset_format = fd.detect_multimodal_dataset = fd.detect_custom_format_heuristic = lambda *a, **k: None + sys.modules['format_detection'] = fd + mm = types.ModuleType('model_mappings') + mm.MODEL_TO_TEMPLATE_MAPPER = {{}} + sys.modules['model_mappings'] = mm + + ns = {{}} + source = open({str(CHAT_TEMPLATES)!r}).read() + source = source.replace('from .format_detection import', 'from format_detection import') + source = source.replace('from .model_mappings import', 'from model_mappings import') + exec(source, ns) + + # Import succeeds -- this is the fix + assert 'apply_chat_template_to_dataset' in ns + print("OK: import succeeded") + + # Calling alpaca branch triggers lazy torch import inside the try block. + # The function catches the error and returns it in the errors list. + dataset_info = {{ + 'dataset': type('D', (), {{'map': lambda *a, **k: None}})(), + 'final_format': 'alpaca', + 'chat_column': None, + 'is_standardized': True, + 'warnings': [], + }} + result = ns['apply_chat_template_to_dataset'](dataset_info, None) + # The function has a try/except that catches the error gracefully + if not result['success']: + print("OK: call-time failure caught gracefully") + else: + print("OK: call succeeded (unexpected but not a crash)") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert ( + result.returncode == 0 + ), f"Should not crash at import time:\n{result.stderr.decode()}" + assert b"OK: import succeeded" in result.stdout + + +# =========================================================================== +# Group 4: Hardware Detection Without Torch (3 tests) +# =========================================================================== + + +class TestHardwareDetectionNoTorch: + """Hardware module works without torch, falling back to CPU.""" + + def test_detect_hardware_no_torch(self, no_torch_venv, sandbox_dir): + """detect_hardware() returns CPU device when torch is not installed.""" + _write_loggers_stub(sandbox_dir) + _write_structlog_stub(sandbox_dir) + + code = textwrap.dedent(f"""\ + import sys + sys.path.insert(0, {str(sandbox_dir)!r}) + source = open({str(HARDWARE_PY)!r}).read() + ns = {{'__name__': '__test__'}} + exec(source, ns) + device = ns['detect_hardware']() + assert device == ns['DeviceType'].CPU + assert ns['CHAT_ONLY'] is True + print("OK: detect_hardware returned CPU, CHAT_ONLY=True") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}" + assert b"OK:" in result.stdout + + def test_get_package_versions_no_torch(self, no_torch_venv, sandbox_dir): + """get_package_versions() returns torch=None, cuda=None without torch.""" + _write_loggers_stub(sandbox_dir) + _write_structlog_stub(sandbox_dir) + + code = textwrap.dedent(f"""\ + import sys + sys.path.insert(0, {str(sandbox_dir)!r}) + source = open({str(HARDWARE_PY)!r}).read() + ns = {{'__name__': '__test__'}} + exec(source, ns) + versions = ns['get_package_versions']() + assert versions['torch'] is None, f"Expected torch=None, got {{versions['torch']}}" + assert versions['cuda'] is None, f"Expected cuda=None, got {{versions['cuda']}}" + print("OK: torch=None, cuda=None") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}" + assert b"OK:" in result.stdout + + def test_hardware_module_import_no_torch(self, no_torch_venv, sandbox_dir): + """The hardware module imports and detect_hardware is callable without torch.""" + _write_loggers_stub(sandbox_dir) + _write_structlog_stub(sandbox_dir) + _write_hardware_stub(sandbox_dir) + + # Copy the real hardware module into a sandbox package + hw_sandbox = sandbox_dir / "hw_pkg" + hw_sandbox.mkdir() + (hw_sandbox / "__init__.py").write_text("", encoding = "utf-8") + shutil.copy2(HARDWARE_PY, hw_sandbox / "hardware.py") + + code = textwrap.dedent(f"""\ + import sys + sys.path.insert(0, {str(sandbox_dir)!r}) + source = open({str(hw_sandbox / 'hardware.py')!r}).read() + ns = {{'__name__': '__test__'}} + exec(source, ns) + assert callable(ns['detect_hardware']) + assert callable(ns['get_package_versions']) + assert callable(ns['is_apple_silicon']) + print("OK: all hardware functions accessible") + """) + result = _run_in_sandbox(no_torch_venv, code) + assert result.returncode == 0, f"Failed:\n{result.stderr.decode()}" + assert b"OK:" in result.stdout + + +# =========================================================================== +# Group 5: install.sh Logic (5 tests via bash subprocess) +# =========================================================================== + + +class TestInstallShLogic: + """Test install.sh flag parsing, platform detection, and guard logic.""" + + @pytest.fixture(autouse = True) + def _check_install_sh(self): + if not INSTALL_SH.is_file(): + pytest.skip("install.sh not found") + + def test_python_flag_parsing(self): + """--python flag correctly sets _USER_PYTHON.""" + # Extract flag parser snippet from install.sh and test it + script = textwrap.dedent("""\ + _USER_PYTHON="" + _next_is_python=false + for arg in "$@"; do + if [ "$_next_is_python" = true ]; then + _USER_PYTHON="$arg" + _next_is_python=false + continue + fi + case "$arg" in + --python) _next_is_python=true ;; + esac + done + echo "$_USER_PYTHON" + """) + # Test: --python 3.12 + r = _run_sh(f"{script}" + "\n", timeout = 10) + # Need to pass args to the script + r = subprocess.run( + ["bash", "-c", script + "\n", "_", "--python", "3.12"], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == b"3.12" + + # Test: --local --python 3.11 + r = subprocess.run( + ["bash", "-c", script + "\n", "_", "--local", "--python", "3.11"], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == b"3.11" + + # Test: no --python flag + r = subprocess.run( + ["bash", "-c", script + "\n", "_", "--local"], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == b"" + + def test_python_flag_missing_arg_errors(self): + """--python without a version argument triggers an error.""" + # Extract the flag parser + error guard from install.sh + script = textwrap.dedent("""\ + set -e + _USER_PYTHON="" + _next_is_python=false + for arg in "$@"; do + if [ "$_next_is_python" = true ]; then + _USER_PYTHON="$arg" + _next_is_python=false + continue + fi + case "$arg" in + --python) _next_is_python=true ;; + esac + done + if [ "$_next_is_python" = true ]; then + echo "ERROR: --python requires a version argument" >&2 + exit 1 + fi + echo "$_USER_PYTHON" + """) + r = subprocess.run( + ["bash", "-c", script + "\n", "_", "--python"], + capture_output = True, + timeout = 10, + ) + assert r.returncode != 0 + assert b"ERROR" in r.stderr + + def test_python_version_resolution(self): + """Python version defaults to 3.12 on Intel Mac, 3.13 elsewhere. + --python overrides both.""" + script = textwrap.dedent("""\ + MAC_INTEL="$1" + _USER_PYTHON="$2" + + if [ -n "$_USER_PYTHON" ]; then + PYTHON_VERSION="$_USER_PYTHON" + elif [ "$MAC_INTEL" = true ]; then + PYTHON_VERSION="3.12" + else + PYTHON_VERSION="3.13" + fi + echo "$PYTHON_VERSION" + """) + # Intel Mac, no override + r = subprocess.run( + ["bash", "-c", script + "\n", "_", "true", ""], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == b"3.12" + + # Non-Intel, no override + r = subprocess.run( + ["bash", "-c", script + "\n", "_", "false", ""], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == b"3.13" + + # Intel Mac with --python override + r = subprocess.run( + ["bash", "-c", script + "\n", "_", "true", "3.11"], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == b"3.11" + + def test_mac_intel_detection_snippet(self): + """Architecture detection sets MAC_INTEL correctly for different platforms.""" + script = textwrap.dedent("""\ + OS="$1" + _ARCH="$2" + MAC_INTEL=false + if [ "$OS" = "macos" ] && [ "$_ARCH" = "x86_64" ]; then + MAC_INTEL=true + fi + echo "$MAC_INTEL" + """) + cases = [ + (("macos", "x86_64"), b"true"), + (("macos", "arm64"), b"false"), + (("linux", "x86_64"), b"false"), + (("linux", "aarch64"), b"false"), + ] + for (os_val, arch), expected in cases: + r = subprocess.run( + ["bash", "-c", script + "\n", "_", os_val, arch], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == expected, ( + f"MAC_INTEL for ({os_val}, {arch}): " + f"expected {expected!r}, got {r.stdout.strip()!r}" + ) + + def test_stale_venv_guard_respects_override(self): + """When _USER_PYTHON is set, the stale venv recreation guard is skipped.""" + # The guard: if MAC_INTEL=true && -z _USER_PYTHON && venv exists ... + script = textwrap.dedent("""\ + MAC_INTEL=true + _USER_PYTHON="$1" + _VENV_EXISTS=true # simulate existing venv + + SHOULD_RECREATE=false + if [ "$MAC_INTEL" = true ] && [ -z "$_USER_PYTHON" ] && [ "$_VENV_EXISTS" = true ]; then + SHOULD_RECREATE=true + fi + echo "$SHOULD_RECREATE" + """) + # With override: should NOT recreate + r = subprocess.run( + ["bash", "-c", script + "\n", "_", "3.11"], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == b"false" + + # Without override: SHOULD recreate + r = subprocess.run( + ["bash", "-c", script + "\n", "_", ""], + capture_output = True, + timeout = 10, + ) + assert r.stdout.strip() == b"true" + + +# =========================================================================== +# Group 6: install_python_stack.py NO_TORCH Filtering (4 tests) +# =========================================================================== + + +class TestInstallPythonStackFiltering: + """Test the NO_TORCH filtering logic in install_python_stack.py.""" + + @pytest.fixture(autouse = True) + def _check_install_py(self): + if not INSTALL_PY.is_file(): + pytest.skip("install_python_stack.py not found") + + def test_filter_requirements_removes_torch_deps(self): + """_filter_requirements removes all NO_TORCH_SKIP_PACKAGES from a real extras file.""" + import install_python_stack as ips + + extras = STUDIO_DIR / "backend" / "requirements" / "extras.txt" + if not extras.is_file(): + pytest.skip("extras.txt not found") + + result_path = ips._filter_requirements(extras, ips.NO_TORCH_SKIP_PACKAGES) + filtered = Path(result_path).read_text(encoding = "utf-8").lower() + + for pkg in ["torch-stoi", "timm", "openai-whisper", "transformers-cfg"]: + lines = [ + l.strip() + for l in filtered.splitlines() + if l.strip() and not l.strip().startswith("#") + ] + assert not any( + l.startswith(pkg) for l in lines + ), f"{pkg} should be removed from extras.txt" + + def test_filter_requirements_preserves_non_torch(self): + """Non-torch packages survive NO_TORCH filtering.""" + import install_python_stack as ips + + extras = STUDIO_DIR / "backend" / "requirements" / "extras.txt" + if not extras.is_file(): + pytest.skip("extras.txt not found") + + result_path = ips._filter_requirements(extras, ips.NO_TORCH_SKIP_PACKAGES) + filtered_text = Path(result_path).read_text(encoding = "utf-8").lower() + + must_survive = ["scikit-learn", "loguru", "tiktoken", "einops"] + original_text = extras.read_text(encoding = "utf-8").lower() + for pkg in must_survive: + if pkg in original_text: + assert pkg in filtered_text, f"{pkg} should survive NO_TORCH filtering" + + def test_infer_no_torch_env_var_overrides_platform(self): + """UNSLOTH_NO_TORCH=true on Linux -> True; =false on Intel Mac -> False.""" + import install_python_stack as ips + + # Explicit true on Linux + with ( + mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "true"}), + mock.patch.object(ips, "IS_MAC_INTEL", False), + ): + assert ips._infer_no_torch() is True + + # Explicit false on Intel Mac + with ( + mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "false"}), + mock.patch.object(ips, "IS_MAC_INTEL", True), + ): + assert ips._infer_no_torch() is False + + # Unset on Intel Mac -> True (platform fallback) + env = os.environ.copy() + env.pop("UNSLOTH_NO_TORCH", None) + with ( + mock.patch.dict(os.environ, env, clear = True), + mock.patch.object(ips, "IS_MAC_INTEL", True), + ): + assert ips._infer_no_torch() is True + + def test_no_torch_skips_overrides_and_triton(self): + """When NO_TORCH=True, overrides.txt and triton are skipped (source guard check).""" + import install_python_stack as ips + + source = Path(ips.__file__).read_text(encoding = "utf-8") + + # NO_TORCH guard before overrides + assert ( + "if NO_TORCH:" in source + ), "NO_TORCH guard not found in install_python_stack.py" + + # macOS guard for triton + assert ( + "not IS_WINDOWS and not IS_MACOS" in source + ), "'not IS_WINDOWS and not IS_MACOS' guard for triton not found" + + +# =========================================================================== +# Group 7: Live Server Startup (4 tests) -- Heavyweight +# =========================================================================== + + +def _studio_venv_python() -> Path | None: + """Return the studio venv Python path, or None if not found.""" + py = _venv_python(STUDIO_VENV) + if py.exists(): + return py + return None + + +def _server_port() -> int: + """Find an available port for the test server.""" + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +server = pytest.mark.server + + +@server +class TestLiveServerStartup: + """Live server startup tests. + + These use the existing Studio venv at ~/.unsloth/studio/unsloth_studio. + They temporarily ensure torch is not importable, test server startup, + then leave the venv unchanged. + + Run separately: pytest -m server + """ + + @pytest.fixture(autouse = True) + def _check_studio_venv(self): + py = _studio_venv_python() + if py is None: + pytest.skip("Studio venv not found at ~/.unsloth/studio/unsloth_studio") + + @pytest.fixture(scope = "class") + def server_process(self): + """Start the studio backend server without torch, yield (proc, port), then stop.""" + py = _studio_venv_python() + if py is None: + pytest.skip("Studio venv not found") + + port = _server_port() + backend_dir = BACKEND_DIR + + # Check if torch is installed in the studio venv + check = subprocess.run( + [str(py), "-c", "import torch; print(torch.__version__)"], + capture_output = True, + ) + torch_was_installed = check.returncode == 0 + torch_version = check.stdout.decode().strip() if torch_was_installed else None + + # Uninstall torch if present + if torch_was_installed: + subprocess.run( + [ + str(py), + "-m", + "pip", + "uninstall", + "-y", + "torch", + "torchvision", + "torchaudio", + ], + capture_output = True, + timeout = 120, + ) + + # Start server + env = os.environ.copy() + env["PYTHONPATH"] = str(backend_dir) + proc = subprocess.Popen( + [str(py), str(backend_dir / "run.py"), "--port", str(port)], + env = env, + stdout = subprocess.PIPE, + stderr = subprocess.PIPE, + cwd = str(backend_dir), + ) + + # Wait for server to be ready (poll /api/health) + import urllib.request + import urllib.error + + ready = False + for _ in range(30): + time.sleep(1) + try: + resp = urllib.request.urlopen( + f"http://127.0.0.1:{port}/api/health", timeout = 2 + ) + if resp.status == 200: + ready = True + break + except (urllib.error.URLError, ConnectionRefusedError, OSError): + continue + + if not ready: + stdout, stderr = proc.communicate(timeout = 5) + # Reinstall torch + torchvision + torchaudio + if torch_was_installed and torch_version: + subprocess.run( + [ + str(py), + "-m", + "pip", + "install", + f"torch=={torch_version}", + "torchvision", + "torchaudio", + ], + capture_output = True, + timeout = 300, + ) + server_output = stdout.decode(errors = "replace") + stderr.decode( + errors = "replace" + ) + pytest.skip( + f"Server failed to start within 30 seconds. Output:\n{server_output}" + ) + + yield proc, port + + # Cleanup: stop server, reinstall torch + proc.terminate() + try: + proc.wait(timeout = 10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout = 5) + + if torch_was_installed and torch_version: + subprocess.run( + [ + str(py), + "-m", + "pip", + "install", + f"torch=={torch_version}", + "torchvision", + "torchaudio", + ], + capture_output = True, + timeout = 300, + ) + + def test_server_starts_without_torch(self, server_process): + """Server responds to /api/health with chat_only: true.""" + import json + import urllib.request + + _, port = server_process + resp = urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout = 5) + data = json.loads(resp.read()) + assert data["status"] == "healthy" + assert data["chat_only"] is True + + def test_all_routes_registered(self, server_process): + """OpenAPI spec shows >= 20 paths (server started fully).""" + import json + import urllib.request + + _, port = server_process + resp = urllib.request.urlopen( + f"http://127.0.0.1:{port}/openapi.json", timeout = 5 + ) + spec = json.loads(resp.read()) + assert ( + len(spec.get("paths", {})) >= 20 + ), f"Expected >= 20 routes, got {len(spec.get('paths', {}))}" + + def test_hardware_endpoint_no_torch(self, server_process): + """GET /api/system/hardware returns torch=null, gpu_name=null.""" + import json + import urllib.request + + _, port = server_process + resp = urllib.request.urlopen( + f"http://127.0.0.1:{port}/api/system/hardware", + timeout = 5, + ) + data = json.loads(resp.read()) + versions = data.get("versions", {}) + assert versions.get("torch") is None + assert versions.get("cuda") is None + + def test_server_survives_multiple_requests(self, server_process): + """Hit 5 different endpoints. Server PID should still be alive after.""" + import urllib.request + import urllib.error + + proc, port = server_process + endpoints = [ + "/api/health", + "/openapi.json", + "/api/system/hardware", + "/api/health", + "/docs", + ] + for ep in endpoints: + try: + urllib.request.urlopen(f"http://127.0.0.1:{port}{ep}", timeout = 5) + except urllib.error.HTTPError: + pass # 4xx/5xx is fine -- server didn't crash + except urllib.error.URLError: + pytest.fail(f"Server stopped responding at {ep}") + + assert proc.poll() is None, "Server process should still be running" diff --git a/tests/python/test_no_torch_filtering.py b/tests/python/test_no_torch_filtering.py new file mode 100644 index 0000000000..5c2926a1f1 --- /dev/null +++ b/tests/python/test_no_torch_filtering.py @@ -0,0 +1,753 @@ +"""Tests for install_python_stack NO_TORCH / IS_MACOS filtering logic. + +Covers: +- _filter_requirements unit tests (synthetic + REAL requirements files) +- NO_TORCH / IS_MACOS / IS_WINDOWS env var parsing +- Subprocess-mock of install_python_stack() to verify overrides/triton/filtering + actually happen (or get skipped) under each platform/config combination +- VCS URL and environment marker edge cases in filtering +""" + +from __future__ import annotations + +import importlib +import os +import re +import subprocess +import sys +import textwrap +from pathlib import Path +from unittest import mock + +import pytest + +# Add the studio directory so we can import install_python_stack +STUDIO_DIR = Path(__file__).resolve().parents[2] / "studio" +sys.path.insert(0, str(STUDIO_DIR)) + +import install_python_stack as ips + +# Paths to the REAL requirements files +REQ_ROOT = Path(__file__).resolve().parents[2] / "studio" / "backend" / "requirements" +EXTRAS_TXT = REQ_ROOT / "extras.txt" +EXTRAS_NO_DEPS_TXT = REQ_ROOT / "extras-no-deps.txt" +OVERRIDES_TXT = REQ_ROOT / "overrides.txt" +TRITON_KERNELS_TXT = REQ_ROOT / "triton-kernels.txt" + + +# ── _filter_requirements unit tests (synthetic) ─────────────────────── + + +class TestFilterRequirements: + """Verify _filter_requirements correctly removes packages by prefix.""" + + def _write_req(self, tmp_path: Path, content: str) -> Path: + req = tmp_path / "requirements.txt" + req.write_text(textwrap.dedent(content), encoding = "utf-8") + return req + + def test_filters_no_torch_packages(self, tmp_path): + req = self._write_req( + tmp_path, + """\ + torch-stoi==0.1 + timm>=1.0 + numpy + torchcodec>=0.1 + torch-c-dlpack-ext + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + # Only numpy should remain (non-blank lines) + non_blank = [l.strip() for l in lines if l.strip()] + assert non_blank == ["numpy"], f"Expected only numpy, got: {non_blank}" + + def test_empty_file(self, tmp_path): + req = self._write_req(tmp_path, "") + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + content = Path(result).read_text(encoding = "utf-8") + assert content.strip() == "" + + def test_comments_preserved(self, tmp_path): + req = self._write_req( + tmp_path, + """\ + # torch-stoi is needed for audio + numpy + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + non_blank = [l.strip() for l in lines if l.strip()] + # Comment starts with "#", not "torch-stoi", so it's preserved + assert len(non_blank) == 2 + assert non_blank[0].startswith("#") + assert non_blank[1] == "numpy" + + def test_version_specifiers_filtered(self, tmp_path): + req = self._write_req( + tmp_path, + """\ + torch-stoi>=0.1.0 + timm==1.2.3 + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + non_blank = [l.strip() for l in lines if l.strip()] + assert non_blank == [], f"Expected empty, got: {non_blank}" + + def test_prefix_match_catches_extensions(self, tmp_path): + """Prefix matching catches torch-stoi-extra (correct for pip names).""" + req = self._write_req( + tmp_path, + """\ + torch-stoi-extra + numpy + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + non_blank = [l.strip() for l in lines if l.strip()] + assert non_blank == ["numpy"] + + def test_mixed_case_filtered(self, tmp_path): + """Package names are lowercased before matching.""" + req = self._write_req( + tmp_path, + """\ + Timm>=1.0 + TORCH-STOI + numpy + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + non_blank = [l.strip() for l in lines if l.strip()] + assert non_blank == ["numpy"] + + def test_whitespace_and_blank_lines_preserved(self, tmp_path): + req = self._write_req( + tmp_path, + """\ + numpy + + pandas + + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + content = Path(result).read_text(encoding = "utf-8") + # Blank lines should be preserved (not stripped) + assert "\n\n" in content or content.count("\n") >= 3 + + def test_stacked_windows_and_no_torch_filters(self, tmp_path): + """Both WINDOWS_SKIP_PACKAGES and NO_TORCH_SKIP_PACKAGES applied.""" + req = self._write_req( + tmp_path, + """\ + open_spiel + triton_kernels + torch-stoi + timm + numpy + """, + ) + # First filter Windows packages, then NO_TORCH packages + intermediate = ips._filter_requirements(req, ips.WINDOWS_SKIP_PACKAGES) + result = ips._filter_requirements( + Path(intermediate), ips.NO_TORCH_SKIP_PACKAGES + ) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + non_blank = [l.strip() for l in lines if l.strip()] + assert non_blank == [ + "numpy" + ], f"Expected only numpy after stacked filters, got: {non_blank}" + + def test_vcs_url_with_skip_package_name(self, tmp_path): + """VCS URLs like git+https://...torch-stoi should also be filtered (startswith matches).""" + req = self._write_req( + tmp_path, + """\ + numpy + torch-stoi @ git+https://github.com/example/torch-stoi.git + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + non_blank = [l.strip() for l in lines if l.strip()] + assert non_blank == [ + "numpy" + ], f"VCS URL line should be filtered, got: {non_blank}" + + def test_env_marker_line_filtered(self, tmp_path): + """Package lines with env markers are still filtered by prefix.""" + req = self._write_req( + tmp_path, + """\ + timm>=1.0; python_version>="3.10" + numpy + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + non_blank = [l.strip() for l in lines if l.strip()] + assert non_blank == [ + "numpy" + ], f"Env marker line should be filtered, got: {non_blank}" + + def test_git_plus_url_not_over_matched(self, tmp_path): + """A git+ URL whose path contains a skip package name but does NOT start with it.""" + req = self._write_req( + tmp_path, + """\ + git+https://github.com/meta-pytorch/OpenEnv.git + numpy + """, + ) + result = ips._filter_requirements(req, ips.NO_TORCH_SKIP_PACKAGES) + lines = Path(result).read_text(encoding = "utf-8").splitlines() + non_blank = [l.strip() for l in lines if l.strip()] + # The git+ URL doesn't start with any skip package, so it is preserved + assert len(non_blank) == 2, f"git+ URL should be preserved, got: {non_blank}" + + +# ── Real requirements file filtering ────────────────────────────────── + + +class TestRealRequirementsFiltering: + """Filter the ACTUAL extras.txt and extras-no-deps.txt with NO_TORCH_SKIP_PACKAGES.""" + + @pytest.fixture(autouse = True) + def _check_req_files(self): + if not EXTRAS_TXT.is_file(): + pytest.skip("extras.txt not found in repo") + if not EXTRAS_NO_DEPS_TXT.is_file(): + pytest.skip("extras-no-deps.txt not found in repo") + + def _non_blank_non_comment(self, path: Path) -> list[str]: + """Return non-blank, non-comment lines from a requirements file.""" + lines = path.read_text(encoding = "utf-8").splitlines() + return [l.strip() for l in lines if l.strip() and not l.strip().startswith("#")] + + def test_extras_txt_torch_packages_removed(self): + """extras.txt: all NO_TORCH_SKIP_PACKAGES must be removed, everything else preserved.""" + result = ips._filter_requirements(EXTRAS_TXT, ips.NO_TORCH_SKIP_PACKAGES) + filtered = self._non_blank_non_comment(Path(result)) + original = self._non_blank_non_comment(EXTRAS_TXT) + + # These must be gone + for pkg in ["torch-stoi", "timm", "openai-whisper", "transformers-cfg"]: + assert not any( + l.lower().startswith(pkg) for l in filtered + ), f"{pkg} should be removed from extras.txt" + + # Everything else must remain + expected = [ + l + for l in original + if not any( + l.strip().lower().startswith(p) for p in ips.NO_TORCH_SKIP_PACKAGES + ) + ] + assert filtered == expected, ( + f"Filtered extras.txt should match expected.\n" + f"Missing: {set(expected) - set(filtered)}\n" + f"Extra: {set(filtered) - set(expected)}" + ) + + def test_extras_no_deps_txt_torchcodec_and_dlpack_removed(self): + """extras-no-deps.txt: torchcodec and torch-c-dlpack-ext must be removed.""" + result = ips._filter_requirements( + EXTRAS_NO_DEPS_TXT, ips.NO_TORCH_SKIP_PACKAGES + ) + filtered = self._non_blank_non_comment(Path(result)) + original = self._non_blank_non_comment(EXTRAS_NO_DEPS_TXT) + + for pkg in ["torchcodec", "torch-c-dlpack-ext"]: + assert not any( + l.lower().startswith(pkg) for l in filtered + ), f"{pkg} should be removed from extras-no-deps.txt" + + expected = [ + l + for l in original + if not any( + l.strip().lower().startswith(p) for p in ips.NO_TORCH_SKIP_PACKAGES + ) + ] + assert filtered == expected + + def test_extras_txt_most_packages_preserved(self): + """Ensure a representative set of non-torch packages survive filtering.""" + result = ips._filter_requirements(EXTRAS_TXT, ips.NO_TORCH_SKIP_PACKAGES) + filtered_text = Path(result).read_text(encoding = "utf-8").lower() + + must_survive = ["scikit-learn", "loguru", "tiktoken", "einops", "tabulate"] + for pkg in must_survive: + if pkg in EXTRAS_TXT.read_text(encoding = "utf-8").lower(): + assert pkg in filtered_text, f"{pkg} should survive NO_TORCH filtering" + + def test_extras_no_deps_txt_trl_preserved(self): + """trl should survive NO_TORCH filtering in extras-no-deps.txt.""" + result = ips._filter_requirements( + EXTRAS_NO_DEPS_TXT, ips.NO_TORCH_SKIP_PACKAGES + ) + filtered_text = Path(result).read_text(encoding = "utf-8").lower() + assert "trl" in filtered_text, "trl should survive NO_TORCH filtering" + + +# ── NO_TORCH constant tests ────────────────────────────────────────── + + +class TestNoTorchConstant: + """Verify NO_TORCH is derived correctly from UNSLOTH_NO_TORCH env var.""" + + def _reimport_no_torch(self) -> bool: + return os.environ.get("UNSLOTH_NO_TORCH", "false").lower() in ("1", "true") + + def test_true_lowercase(self): + with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "true"}): + assert self._reimport_no_torch() is True + + def test_true_one(self): + with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "1"}): + assert self._reimport_no_torch() is True + + def test_true_uppercase(self): + with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "TRUE"}): + assert self._reimport_no_torch() is True + + def test_false_string(self): + with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "false"}): + assert self._reimport_no_torch() is False + + def test_false_zero(self): + with mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "0"}): + assert self._reimport_no_torch() is False + + def test_not_set(self): + env = os.environ.copy() + env.pop("UNSLOTH_NO_TORCH", None) + with mock.patch.dict(os.environ, env, clear = True): + assert self._reimport_no_torch() is False + + def test_infer_no_torch_on_intel_mac(self): + """_infer_no_torch falls back to platform detection when env var is unset.""" + env = os.environ.copy() + env.pop("UNSLOTH_NO_TORCH", None) + with ( + mock.patch.dict(os.environ, env, clear = True), + mock.patch.object(ips, "IS_MAC_INTEL", True), + ): + assert ips._infer_no_torch() is True + + def test_infer_no_torch_respects_explicit_false_on_intel_mac(self): + """Explicit UNSLOTH_NO_TORCH=false overrides platform detection.""" + with ( + mock.patch.dict(os.environ, {"UNSLOTH_NO_TORCH": "false"}), + mock.patch.object(ips, "IS_MAC_INTEL", True), + ): + assert ips._infer_no_torch() is False + + def test_infer_no_torch_linux_unset(self): + """On Linux with env var unset, _infer_no_torch returns False.""" + env = os.environ.copy() + env.pop("UNSLOTH_NO_TORCH", None) + with ( + mock.patch.dict(os.environ, env, clear = True), + mock.patch.object(ips, "IS_MAC_INTEL", False), + ): + assert ips._infer_no_torch() is False + + +# ── IS_MACOS constant tests ────────────────────────────────────────── + + +class TestIsMacosConstant: + """Verify IS_MACOS detection logic.""" + + def test_is_macos_matches_platform(self): + import sys + + expected = sys.platform == "darwin" + assert ips.IS_MACOS is expected + + +# ── Subprocess mock of install_python_stack() ───────────────────────── + + +class TestInstallPythonStackSubprocessMock: + """Monkeypatch subprocess.run to capture all pip/uv commands, + then verify which requirements files are used/skipped under + different NO_TORCH / IS_MACOS / IS_WINDOWS configurations.""" + + @pytest.fixture(autouse = True) + def _check_req_files(self): + """Skip if requirements files are missing.""" + for f in [EXTRAS_TXT, EXTRAS_NO_DEPS_TXT, OVERRIDES_TXT]: + if not f.is_file(): + pytest.skip(f"{f.name} not found in repo") + + def _capture_install( + self, + no_torch: bool, + is_macos: bool, + is_windows: bool, + *, + skip_base: bool = True, + ): + """Run install_python_stack() with mocked subprocess, capturing all commands. + + Returns a list of string-joined commands (each element is ' '.join(cmd)). + """ + captured_cmds: list[list[str]] = [] + + def mock_run(cmd, **kw): + captured_cmds.append( + list(cmd) if isinstance(cmd, (list, tuple)) else [str(cmd)] + ) + return subprocess.CompletedProcess(cmd, 0, b"", b"") + + env = {"SKIP_STUDIO_BASE": "1"} if skip_base else {} + + with ( + mock.patch.object(ips, "NO_TORCH", no_torch), + mock.patch.object(ips, "IS_MACOS", is_macos), + mock.patch.object(ips, "IS_WINDOWS", is_windows), + mock.patch.object(ips, "USE_UV", True), + mock.patch.object(ips, "UV_NEEDS_SYSTEM", False), + mock.patch.object(ips, "VERBOSE", False), + mock.patch("subprocess.run", side_effect = mock_run), + mock.patch.object(ips, "_bootstrap_uv", return_value = True), + mock.patch.object( + ips, "LOCAL_DD_UNSTRUCTURED_PLUGIN", Path("/fake/plugin") + ), + mock.patch("pathlib.Path.is_dir", return_value = True), + mock.patch("pathlib.Path.is_file", return_value = True), + ): + with mock.patch.dict(os.environ, env, clear = False): + ips.install_python_stack() + + return [" ".join(str(c) for c in cmd) for cmd in captured_cmds] + + def _cmds_contain_file(self, cmds: list[str], filename: str) -> bool: + """Check if any captured command references the given filename.""" + return any(filename in cmd for cmd in cmds) + + # -- NO_TORCH=True, IS_MACOS=True (Intel Mac scenario) -- + + def test_no_torch_macos_skips_overrides(self): + """With NO_TORCH=True, overrides.txt pip_install must NOT be called.""" + cmds = self._capture_install(no_torch = True, is_macos = True, is_windows = False) + assert not self._cmds_contain_file( + cmds, "overrides.txt" + ), "overrides.txt should be skipped when NO_TORCH=True" + + def test_no_torch_macos_skips_triton(self): + """With IS_MACOS=True, triton-kernels.txt must NOT be called.""" + cmds = self._capture_install(no_torch = True, is_macos = True, is_windows = False) + assert not self._cmds_contain_file( + cmds, "triton-kernels.txt" + ), "triton-kernels.txt should be skipped on macOS" + + def test_no_torch_macos_extras_called(self): + """With NO_TORCH=True, extras.txt is still called (but filtered).""" + cmds = self._capture_install(no_torch = True, is_macos = True, is_windows = False) + has_extras = self._cmds_contain_file(cmds, "extras.txt") or any( + "-r" in cmd and "tmp" in cmd.lower() for cmd in cmds + ) + assert has_extras, "extras.txt (or its filtered temp) should be called" + + def test_no_torch_macos_extras_no_deps_called(self): + """With NO_TORCH=True, extras-no-deps.txt is still called (but filtered).""" + cmds = self._capture_install(no_torch = True, is_macos = True, is_windows = False) + has_extras_nd = self._cmds_contain_file(cmds, "extras-no-deps.txt") or any( + "-r" in cmd and "tmp" in cmd.lower() for cmd in cmds + ) + assert ( + has_extras_nd + ), "extras-no-deps.txt (or its filtered temp) should be called" + + # -- IS_WINDOWS=True + NO_TORCH=True (stacked) -- + + def test_windows_no_torch_skips_overrides(self): + """Windows+NO_TORCH: overrides.txt must be skipped.""" + cmds = self._capture_install(no_torch = True, is_macos = False, is_windows = True) + assert not self._cmds_contain_file( + cmds, "overrides.txt" + ), "overrides.txt should be skipped with NO_TORCH=True on Windows" + + def test_windows_no_torch_skips_triton(self): + """Windows: triton-kernels.txt must be skipped (IS_WINDOWS guard).""" + cmds = self._capture_install(no_torch = True, is_macos = False, is_windows = True) + assert not self._cmds_contain_file( + cmds, "triton-kernels.txt" + ), "triton-kernels.txt should be skipped on Windows" + + # -- Normal Linux path (NO_TORCH=False, IS_MACOS=False, IS_WINDOWS=False) -- + + def test_normal_linux_includes_overrides(self): + """Normal Linux: overrides.txt IS called.""" + cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False) + assert self._cmds_contain_file( + cmds, "overrides.txt" + ), "overrides.txt should be called on normal Linux" + + def test_normal_linux_includes_triton(self): + """Normal Linux: triton-kernels.txt IS called.""" + cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False) + assert self._cmds_contain_file( + cmds, "triton-kernels.txt" + ), "triton-kernels.txt should be called on normal Linux" + + def test_normal_linux_includes_extras(self): + """Normal Linux: extras.txt IS called (no filtering).""" + cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False) + assert self._cmds_contain_file( + cmds, "extras.txt" + ), "extras.txt should be called on normal Linux" + + def test_normal_linux_includes_extras_no_deps(self): + """Normal Linux: extras-no-deps.txt IS called (no filtering).""" + cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = False) + assert self._cmds_contain_file( + cmds, "extras-no-deps.txt" + ), "extras-no-deps.txt should be called on normal Linux" + + # -- Windows-only (NO_TORCH=False) to verify triton is still skipped -- + + def test_windows_only_skips_triton(self): + """Windows (without NO_TORCH): triton still skipped.""" + cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = True) + assert not self._cmds_contain_file( + cmds, "triton-kernels.txt" + ), "triton-kernels.txt should be skipped on Windows even without NO_TORCH" + + def test_windows_only_includes_overrides(self): + """Windows (without NO_TORCH): overrides IS called (via filtered temp file). + + On Windows, all req files go through _filter_requirements(WINDOWS_SKIP_PACKAGES), + so the command uses a temp file, not overrides.txt directly. We check for + --reinstall (uv translation of --force-reinstall) which is unique to overrides. + """ + cmds = self._capture_install(no_torch = False, is_macos = False, is_windows = True) + assert any( + "--reinstall" in cmd for cmd in cmds + ), "overrides step (--reinstall) should be called on Windows when NO_TORCH=False" + + # -- Update path (skip_base=False) to verify no-torch mode is durable -- + + def test_update_path_intel_macos_still_skips_overrides(self): + """Update path (no SKIP_STUDIO_BASE): overrides still skipped on Intel Mac.""" + cmds = self._capture_install( + no_torch = True, is_macos = True, is_windows = False, skip_base = False + ) + assert not self._cmds_contain_file( + cmds, "overrides.txt" + ), "overrides.txt should be skipped on Intel Mac even via studio update" + + def test_update_path_intel_macos_still_skips_triton(self): + """Update path (no SKIP_STUDIO_BASE): triton still skipped on macOS.""" + cmds = self._capture_install( + no_torch = True, is_macos = True, is_windows = False, skip_base = False + ) + assert not self._cmds_contain_file( + cmds, "triton-kernels.txt" + ), "triton-kernels.txt should be skipped on macOS even via studio update" + + +# ── Overrides skip structural checks ───────────────────────────────── + + +class TestOverridesSkip: + """Verify overrides.txt is skipped when NO_TORCH is True (source-level check).""" + + def test_no_torch_guard_exists_in_source(self): + """The install_python_stack source must contain a NO_TORCH guard around overrides.""" + source = Path(ips.__file__).read_text(encoding = "utf-8") + assert ( + "if NO_TORCH:" in source + ), "NO_TORCH guard not found in install_python_stack.py" + + def test_overrides_skipped_when_no_torch(self): + """With NO_TORCH=True on the module, pip_install should NOT be called for overrides.""" + source = Path(ips.__file__).read_text(encoding = "utf-8") + overrides_match = re.search(r"if NO_TORCH:.*?overrides", source, re.DOTALL) + assert ( + overrides_match is not None + ), "Expected NO_TORCH conditional before overrides install" + + +# ── install.sh --no-torch flag tests ────────────────────────────────── + + +class TestInstallShNoTorchFlag: + """Verify install.sh has the --no-torch flag and SKIP_TORCH variable.""" + + @pytest.fixture(autouse = True) + def _check_install_sh(self): + install_sh = Path(__file__).resolve().parents[2] / "install.sh" + if not install_sh.is_file(): + pytest.skip("install.sh not found") + self.install_sh = install_sh + self.source = install_sh.read_text(encoding = "utf-8") + + def test_no_torch_flag_in_case_statement(self): + """--no-torch must appear in the flag parser case statement.""" + assert ( + "--no-torch)" in self.source + ), "--no-torch not found in install.sh flag parser" + + def test_no_torch_flag_variable_initialized(self): + """_NO_TORCH_FLAG must be initialized to false.""" + assert ( + "_NO_TORCH_FLAG=false" in self.source + ), "_NO_TORCH_FLAG=false not found in install.sh" + + def test_skip_torch_variable_exists(self): + """SKIP_TORCH variable must be defined.""" + assert ( + "SKIP_TORCH=false" in self.source + ), "SKIP_TORCH=false not found in install.sh" + assert ( + "SKIP_TORCH=true" in self.source + ), "SKIP_TORCH=true not found in install.sh" + + def test_skip_torch_driven_by_flag_and_mac_intel(self): + """SKIP_TORCH must check both _NO_TORCH_FLAG and MAC_INTEL.""" + assert ( + "_NO_TORCH_FLAG" in self.source + ), "_NO_TORCH_FLAG not referenced in SKIP_TORCH logic" + assert ( + "MAC_INTEL" in self.source + ), "MAC_INTEL not referenced in SKIP_TORCH logic" + + def test_unsloth_no_torch_uses_skip_torch(self): + """UNSLOTH_NO_TORCH must reference $SKIP_TORCH, not $MAC_INTEL.""" + import re + + matches = re.findall(r'UNSLOTH_NO_TORCH="\$(\w+)"', self.source) + for var in matches: + assert ( + var == "SKIP_TORCH" + ), f"UNSLOTH_NO_TORCH references ${var} instead of $SKIP_TORCH" + + def test_cpu_hint_message_exists(self): + """CPU hint message must exist in install.sh.""" + assert ( + "No NVIDIA GPU detected" in self.source + ), "CPU hint message not found in install.sh" + assert ( + "--no-torch" in self.source + ), "--no-torch suggestion not found in CPU hint" + + def test_no_torch_flag_parsing_subprocess(self): + """--no-torch flag sets _NO_TORCH_FLAG=true (subprocess test).""" + script = textwrap.dedent("""\ + _NO_TORCH_FLAG=false + _next_is_package=false + STUDIO_LOCAL_INSTALL=false + PACKAGE_NAME="unsloth" + for arg in "$@"; do + if [ "$_next_is_package" = true ]; then + PACKAGE_NAME="$arg" + _next_is_package=false + continue + fi + case "$arg" in + --local) STUDIO_LOCAL_INSTALL=true ;; + --package) _next_is_package=true ;; + --no-torch) _NO_TORCH_FLAG=true ;; + esac + done + echo "$_NO_TORCH_FLAG" + """) + result = subprocess.run( + ["bash", "-c", script, "_", "--no-torch"], + capture_output = True, + text = True, + ) + assert ( + result.stdout.strip() == "true" + ), f"Expected _NO_TORCH_FLAG=true, got: {result.stdout.strip()}" + + def test_no_torch_with_local_flag(self): + """--no-torch and --local can be used together.""" + script = textwrap.dedent("""\ + _NO_TORCH_FLAG=false + _next_is_package=false + STUDIO_LOCAL_INSTALL=false + PACKAGE_NAME="unsloth" + for arg in "$@"; do + if [ "$_next_is_package" = true ]; then + PACKAGE_NAME="$arg" + _next_is_package=false + continue + fi + case "$arg" in + --local) STUDIO_LOCAL_INSTALL=true ;; + --package) _next_is_package=true ;; + --no-torch) _NO_TORCH_FLAG=true ;; + esac + done + echo "$_NO_TORCH_FLAG $STUDIO_LOCAL_INSTALL" + """) + result = subprocess.run( + ["bash", "-c", script, "_", "--local", "--no-torch"], + capture_output = True, + text = True, + ) + assert ( + result.stdout.strip() == "true true" + ), f"Expected 'true true', got: {result.stdout.strip()}" + + def test_cpu_hint_only_when_not_skip_torch(self): + """CPU hint should only print when SKIP_TORCH=false and OS!=macos.""" + script = textwrap.dedent("""\ + TORCH_INDEX_URL="https://download.pytorch.org/whl/cpu" + SKIP_TORCH=false + OS="linux" + case "$TORCH_INDEX_URL" in + */cpu) + if [ "$SKIP_TORCH" = false ] && [ "$OS" != "macos" ]; then + echo "HINT_PRINTED" + fi + ;; + esac + """) + result = subprocess.run( + ["bash", "-c", script], + capture_output = True, + text = True, + ) + assert "HINT_PRINTED" in result.stdout, "CPU hint should print" + + # With SKIP_TORCH=true, hint should NOT print + script2 = script.replace("SKIP_TORCH=false", "SKIP_TORCH=true") + result2 = subprocess.run( + ["bash", "-c", script2], + capture_output = True, + text = True, + ) + assert ( + "HINT_PRINTED" not in result2.stdout + ), "CPU hint should NOT print when SKIP_TORCH=true" + + +# ── Triton macOS skip structural checks ────────────────────────────── + + +class TestTritonMacosSkip: + """Verify triton is skipped on macOS (source-level check).""" + + def test_triton_guard_in_source(self): + """Source must skip triton on both Windows and macOS.""" + source = Path(ips.__file__).read_text(encoding = "utf-8") + assert ( + "not IS_MACOS" in source + ), "IS_MACOS guard for triton not found in install_python_stack.py" + assert ( + "not IS_WINDOWS and not IS_MACOS" in source + ), "Expected 'not IS_WINDOWS and not IS_MACOS' guard for triton" diff --git a/tests/python/test_studio_import_no_torch.py b/tests/python/test_studio_import_no_torch.py new file mode 100644 index 0000000000..5592a282ff --- /dev/null +++ b/tests/python/test_studio_import_no_torch.py @@ -0,0 +1,582 @@ +"""End-to-end sandbox tests: Studio modules in isolated no-torch venvs. + +Covers: +- Python 3.12 and 3.13 venv creation (Intel Mac uses 3.12, Apple Silicon/Linux 3.13) +- data_collators.py loads and dataclasses instantiate without torch +- chat_templates.py top-level exec works with stubs for relative imports +- Negative control: prepending 'import torch' fails in no-torch venv +- Negative control: installing torchao (from overrides.txt) fails in no-torch venv +- AST structural checks for top-level torch imports +""" + +from __future__ import annotations + +import ast +import os +import shutil +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DATA_COLLATORS = ( + REPO_ROOT / "studio" / "backend" / "utils" / "datasets" / "data_collators.py" +) +CHAT_TEMPLATES = ( + REPO_ROOT / "studio" / "backend" / "utils" / "datasets" / "chat_templates.py" +) +FORMAT_CONVERSION = ( + REPO_ROOT / "studio" / "backend" / "utils" / "datasets" / "format_conversion.py" +) + + +def _has_uv() -> bool: + return shutil.which("uv") is not None + + +def _create_venv(venv_dir: Path, python_version: str) -> Path | None: + """Create a uv venv at the given Python version. Returns python path or None.""" + result = subprocess.run( + ["uv", "venv", str(venv_dir), "--python", python_version], + capture_output = True, + ) + if result.returncode != 0: + return None + venv_python = venv_dir / "bin" / "python" + if not venv_python.exists(): + venv_python = venv_dir / "Scripts" / "python.exe" + return venv_python if venv_python.exists() else None + + +@pytest.fixture(params = ["3.12", "3.13"], scope = "module") +def no_torch_venv(request, tmp_path_factory): + """Create a temporary venv at the requested Python version with no torch. + + Parametrized for 3.12 (Intel Mac) and 3.13 (Apple Silicon / Linux). + """ + if not _has_uv(): + pytest.skip("uv not available") + + py_version = request.param + venv_dir = tmp_path_factory.mktemp(f"no_torch_venv_{py_version}") + venv_python = _create_venv(venv_dir, py_version) + if venv_python is None: + pytest.skip(f"Could not create Python {py_version} venv") + + # Verify torch is NOT importable + check = subprocess.run( + [str(venv_python), "-c", "import torch"], + capture_output = True, + ) + assert ( + check.returncode != 0 + ), f"torch should NOT be importable in fresh {py_version} venv" + + return str(venv_python) + + +# ── AST structural checks ───────────────────────────────────────────── + + +class TestDataCollatorsAST: + """Static analysis: data_collators.py has no top-level torch imports.""" + + def test_ast_parse(self): + """data_collators.py must be valid Python syntax.""" + source = DATA_COLLATORS.read_text(encoding = "utf-8") + tree = ast.parse(source, filename = str(DATA_COLLATORS)) + assert tree is not None + + def test_no_top_level_torch_import(self): + """No top-level 'import torch' or 'from torch' statements.""" + source = DATA_COLLATORS.read_text(encoding = "utf-8") + tree = ast.parse(source) + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert not alias.name.startswith( + "torch" + ), f"Top-level 'import {alias.name}' found at line {node.lineno}" + elif isinstance(node, ast.ImportFrom): + if node.module: + assert not node.module.startswith( + "torch" + ), f"Top-level 'from {node.module}' found at line {node.lineno}" + + +class TestChatTemplatesAST: + """Static analysis: chat_templates.py has no top-level torch imports.""" + + def test_ast_parse(self): + """chat_templates.py must be valid Python syntax.""" + source = CHAT_TEMPLATES.read_text(encoding = "utf-8") + tree = ast.parse(source, filename = str(CHAT_TEMPLATES)) + assert tree is not None + + def test_no_top_level_torch_import(self): + """No top-level 'import torch' or 'from torch' at module level.""" + source = CHAT_TEMPLATES.read_text(encoding = "utf-8") + tree = ast.parse(source) + for node in ast.iter_child_nodes(tree): + if isinstance(node, ast.Import): + for alias in node.names: + assert not alias.name.startswith( + "torch" + ), f"Top-level 'import {alias.name}' found at line {node.lineno}" + elif isinstance(node, ast.ImportFrom): + if node.module: + assert not node.module.startswith( + "torch" + ), f"Top-level 'from {node.module}' found at line {node.lineno}" + + def test_torch_imports_only_inside_functions(self): + """All 'from torch' imports must be inside function/method bodies.""" + source = CHAT_TEMPLATES.read_text(encoding = "utf-8") + tree = ast.parse(source) + torch_imports = [] + for node in ast.walk(tree): + if isinstance(node, (ast.Import, ast.ImportFrom)): + module = None + if isinstance(node, ast.ImportFrom): + module = node.module + elif isinstance(node, ast.Import): + module = node.names[0].name if node.names else None + if module and module.startswith("torch"): + torch_imports.append(node) + + top_level = set(id(n) for n in ast.iter_child_nodes(tree)) + for imp in torch_imports: + assert id(imp) not in top_level, ( + f"torch import at line {imp.lineno} is at top level" + " (should be inside a function)" + ) + + +# ── data_collators.py: exec + dataclass instantiation in no-torch venv ── + + +class TestDataCollatorsNoTorchVenv: + """Run data_collators.py in an isolated no-torch venv, verify classes load.""" + + def test_exec_in_no_torch_venv(self, no_torch_venv): + """data_collators.py executes in a venv without torch (with loggers stub).""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(DATA_COLLATORS)!r}).read()) + print("OK: exec succeeded") + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode == 0 + ), f"data_collators.py failed in no-torch venv:\n{result.stderr.decode()}" + assert b"OK: exec succeeded" in result.stdout + + def test_dataclass_speech_collator_instantiable(self, no_torch_venv): + """DataCollatorSpeechSeq2SeqWithPadding can be instantiated with processor=None.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(DATA_COLLATORS)!r}).read()) + obj = DataCollatorSpeechSeq2SeqWithPadding(processor=None) + assert obj.processor is None, "processor should be None" + print("OK: DataCollatorSpeechSeq2SeqWithPadding instantiated") + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode == 0 + ), f"DataCollatorSpeechSeq2SeqWithPadding failed:\n{result.stderr.decode()}" + assert b"OK: DataCollatorSpeechSeq2SeqWithPadding instantiated" in result.stdout + + def test_dataclass_deepseek_collator_instantiable(self, no_torch_venv): + """DeepSeekOCRDataCollator can be instantiated with processor=None.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(DATA_COLLATORS)!r}).read()) + obj = DeepSeekOCRDataCollator(processor=None) + assert obj.processor is None, "processor should be None" + assert obj.max_length == 2048, "default max_length should be 2048" + assert obj.ignore_index == -100, "default ignore_index should be -100" + print("OK: DeepSeekOCRDataCollator instantiated") + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode == 0 + ), f"DeepSeekOCRDataCollator failed:\n{result.stderr.decode()}" + assert b"OK: DeepSeekOCRDataCollator instantiated" in result.stdout + + def test_dataclass_vlm_collator_instantiable(self, no_torch_venv): + """VLMDataCollator can be instantiated with processor=None.""" + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({str(DATA_COLLATORS)!r}).read()) + obj = VLMDataCollator(processor=None) + assert obj.processor is None + assert obj.mask_input_tokens is True, "default mask_input_tokens should be True" + print("OK: VLMDataCollator instantiated") + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode == 0 + ), f"VLMDataCollator failed:\n{result.stderr.decode()}" + assert b"OK: VLMDataCollator instantiated" in result.stdout + + +# ── chat_templates.py: exec in no-torch venv ───────────────────────── + + +class TestChatTemplatesNoTorchVenv: + """Run chat_templates.py in an isolated no-torch venv with stubs.""" + + def test_exec_with_stubs(self, no_torch_venv): + """chat_templates.py top-level exec works with stubs for relative imports.""" + code = textwrap.dedent(f"""\ + import sys, types + + # Stub loggers + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None, 'warning': lambda s, m: None, 'debug': lambda s, m: None}})() + sys.modules['loggers'] = loggers + + # Stub relative imports (.format_detection, .model_mappings) + format_detection = types.ModuleType('format_detection') + format_detection.detect_dataset_format = lambda *a, **k: None + format_detection.detect_multimodal_dataset = lambda *a, **k: None + format_detection.detect_custom_format_heuristic = lambda *a, **k: None + sys.modules['format_detection'] = format_detection + + model_mappings = types.ModuleType('model_mappings') + model_mappings.MODEL_TO_TEMPLATE_MAPPER = {{}} + sys.modules['model_mappings'] = model_mappings + + # Read and transform the source: replace relative imports with absolute + source = open({str(CHAT_TEMPLATES)!r}).read() + source = source.replace('from .format_detection import', 'from format_detection import') + source = source.replace('from .model_mappings import', 'from model_mappings import') + + exec(source) + + # Verify module-level constants are defined + ns = dict(locals()) + assert 'DEFAULT_ALPACA_TEMPLATE' in ns, "DEFAULT_ALPACA_TEMPLATE not defined after exec" + print("OK: chat_templates.py exec succeeded") + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode == 0 + ), f"chat_templates.py failed in no-torch venv:\n{result.stderr.decode()}" + assert b"OK: chat_templates.py exec succeeded" in result.stdout + + def test_default_alpaca_template_defined(self, no_torch_venv): + """DEFAULT_ALPACA_TEMPLATE constant is accessible after exec.""" + code = textwrap.dedent(f"""\ + import sys, types + + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: type('L', (), {{'info': lambda s, m: None, 'warning': lambda s, m: None, 'debug': lambda s, m: None}})() + sys.modules['loggers'] = loggers + + format_detection = types.ModuleType('format_detection') + format_detection.detect_dataset_format = lambda *a, **k: None + format_detection.detect_multimodal_dataset = lambda *a, **k: None + format_detection.detect_custom_format_heuristic = lambda *a, **k: None + sys.modules['format_detection'] = format_detection + + model_mappings = types.ModuleType('model_mappings') + model_mappings.MODEL_TO_TEMPLATE_MAPPER = {{}} + sys.modules['model_mappings'] = model_mappings + + ns = {{}} + source = open({str(CHAT_TEMPLATES)!r}).read() + source = source.replace('from .format_detection import', 'from format_detection import') + source = source.replace('from .model_mappings import', 'from model_mappings import') + exec(source, ns) + + assert 'DEFAULT_ALPACA_TEMPLATE' in ns, "DEFAULT_ALPACA_TEMPLATE not defined" + assert 'Instruction' in ns['DEFAULT_ALPACA_TEMPLATE'], "Template content unexpected" + print("OK: DEFAULT_ALPACA_TEMPLATE defined and valid") + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode == 0 + ), f"DEFAULT_ALPACA_TEMPLATE check failed:\n{result.stderr.decode()}" + assert b"OK: DEFAULT_ALPACA_TEMPLATE defined and valid" in result.stdout + + +# ── format_conversion.py: AST + runtime tests ──────────────────────── + + +class TestFormatConversionAST: + """Static analysis: format_conversion.py torch imports are guarded.""" + + def test_ast_parse(self): + """format_conversion.py must be valid Python syntax.""" + source = FORMAT_CONVERSION.read_text(encoding = "utf-8") + tree = ast.parse(source, filename = str(FORMAT_CONVERSION)) + assert tree is not None + + def test_no_bare_torch_import_in_functions(self): + """All 'from torch' imports in function bodies must be inside try/except.""" + source = FORMAT_CONVERSION.read_text(encoding = "utf-8") + tree = ast.parse(source) + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for child in ast.walk(node): + if ( + isinstance(child, ast.ImportFrom) + and child.module + and child.module.startswith("torch") + ): + # This torch import must be inside a Try node + found_in_try = False + for try_node in ast.walk(node): + if isinstance(try_node, ast.Try): + for try_child in ast.walk(try_node): + if try_child is child: + found_in_try = True + break + if found_in_try: + break + assert found_in_try, ( + f"torch import at line {child.lineno} in {node.name}() " + "is not inside a try/except block" + ) + + +class TestFormatConversionNoTorchVenv: + """Run format_conversion.py functions in a no-torch venv.""" + + def test_convert_chatml_to_alpaca_no_torch(self, no_torch_venv): + """convert_chatml_to_alpaca works without torch (via try/except ImportError).""" + code = textwrap.dedent(f"""\ + import sys, types + + # Stub loggers + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: type('L', (), {{ + 'info': lambda s, m: None, + 'warning': lambda s, m: None, + 'debug': lambda s, m: None, + }})() + sys.modules['loggers'] = loggers + + # Stub datasets.IterableDataset (HF datasets, not torch) + datasets_mod = types.ModuleType('datasets') + datasets_mod.IterableDataset = type('IterableDataset', (), {{}}) + sys.modules['datasets'] = datasets_mod + + # Stub utils.hardware + utils_mod = types.ModuleType('utils') + hardware_mod = types.ModuleType('utils.hardware') + hardware_mod.dataset_map_num_proc = lambda n=None: 1 + utils_mod.hardware = hardware_mod + sys.modules['utils'] = utils_mod + sys.modules['utils.hardware'] = hardware_mod + + # Read and exec format_conversion.py + source = open({str(FORMAT_CONVERSION)!r}).read() + source = source.replace('from .format_detection import', 'from format_detection import') + ns = {{'__name__': '__test__'}} + exec(source, ns) + + # Test convert_chatml_to_alpaca with a simple dataset + class FakeDataset: + def map(self, fn, **kw): + result = fn({{ + 'messages': [[ + {{'role': 'user', 'content': 'Hello'}}, + {{'role': 'assistant', 'content': 'Hi there'}}, + ]] + }}) + return result + + result = ns['convert_chatml_to_alpaca'](FakeDataset()) + assert 'instruction' in result, f"Expected 'instruction' in result, got {{result.keys()}}" + assert result['instruction'] == ['Hello'] + assert result['output'] == ['Hi there'] + print("OK: convert_chatml_to_alpaca works without torch") + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode == 0 + ), f"convert_chatml_to_alpaca failed without torch:\n{result.stderr.decode()}" + assert b"OK: convert_chatml_to_alpaca works without torch" in result.stdout + + def test_convert_alpaca_to_chatml_no_torch(self, no_torch_venv): + """convert_alpaca_to_chatml works without torch (via try/except ImportError).""" + code = textwrap.dedent(f"""\ + import sys, types + + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: type('L', (), {{ + 'info': lambda s, m: None, + 'warning': lambda s, m: None, + 'debug': lambda s, m: None, + }})() + sys.modules['loggers'] = loggers + + datasets_mod = types.ModuleType('datasets') + datasets_mod.IterableDataset = type('IterableDataset', (), {{}}) + sys.modules['datasets'] = datasets_mod + + utils_mod = types.ModuleType('utils') + hardware_mod = types.ModuleType('utils.hardware') + hardware_mod.dataset_map_num_proc = lambda n=None: 1 + utils_mod.hardware = hardware_mod + sys.modules['utils'] = utils_mod + sys.modules['utils.hardware'] = hardware_mod + + source = open({str(FORMAT_CONVERSION)!r}).read() + source = source.replace('from .format_detection import', 'from format_detection import') + ns = {{'__name__': '__test__'}} + exec(source, ns) + + class FakeDataset: + def map(self, fn, **kw): + result = fn({{ + 'instruction': ['Write a poem'], + 'input': [''], + 'output': ['Roses are red'], + }}) + return result + + result = ns['convert_alpaca_to_chatml'](FakeDataset()) + assert 'conversations' in result + convo = result['conversations'][0] + assert convo[0]['role'] == 'user' + assert convo[1]['role'] == 'assistant' + print("OK: convert_alpaca_to_chatml works without torch") + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode == 0 + ), f"convert_alpaca_to_chatml failed without torch:\n{result.stderr.decode()}" + assert b"OK: convert_alpaca_to_chatml works without torch" in result.stdout + + +# ── Negative controls ───────────────────────────────────────────────── + + +class TestNegativeControls: + """Prove the fix is necessary by showing what fails WITHOUT it.""" + + def test_import_torch_prepended_fails(self, no_torch_venv): + """Prepending 'import torch' to data_collators.py causes ModuleNotFoundError.""" + with tempfile.NamedTemporaryFile( + mode = "w", suffix = ".py", delete = False, encoding = "utf-8" + ) as f: + f.write("import torch\n") + f.write(DATA_COLLATORS.read_text(encoding = "utf-8")) + temp_file = f.name + + try: + code = textwrap.dedent(f"""\ + import sys, types + loggers = types.ModuleType('loggers') + loggers.get_logger = lambda n: None + sys.modules['loggers'] = loggers + exec(open({temp_file!r}).read()) + """) + result = subprocess.run( + [no_torch_venv, "-c", code], + capture_output = True, + timeout = 30, + ) + assert ( + result.returncode != 0 + ), "Expected failure when 'import torch' is prepended" + assert ( + b"ModuleNotFoundError" in result.stderr + or b"ImportError" in result.stderr + ), f"Expected ImportError, got:\n{result.stderr.decode()}" + finally: + os.unlink(temp_file) + + def test_torchao_install_fails_no_torch_venv(self, no_torch_venv): + """Installing torchao (from overrides.txt) fails in a no-torch venv. + + This proves the overrides.txt skip is necessary for Intel Mac. + """ + result = subprocess.run( + [ + no_torch_venv, + "-m", + "pip", + "install", + "torchao==0.14.0", + "--dry-run", + ], + capture_output = True, + timeout = 60, + ) + if result.returncode != 0: + # torchao install/resolution failed as expected + pass + else: + # pip dry-run may not catch dependency issues; verify torch is missing + check = subprocess.run( + [no_torch_venv, "-c", "import torch"], + capture_output = True, + ) + assert ( + check.returncode != 0 + ), "torch should not be importable -- torchao would fail at runtime" + + def test_direct_torch_import_fails(self, no_torch_venv): + """Direct 'import torch' fails in the no-torch venv.""" + result = subprocess.run( + [no_torch_venv, "-c", "import torch; print('torch loaded')"], + capture_output = True, + timeout = 30, + ) + assert result.returncode != 0, "import torch should fail in no-torch venv" + assert ( + b"ModuleNotFoundError" in result.stderr or b"ImportError" in result.stderr + ) diff --git a/tests/run_all.sh b/tests/run_all.sh index d7fdb38e74..a1516aa6c8 100755 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -6,11 +6,14 @@ TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" echo "=== Bash tests ===" sh "$TESTS_DIR/sh/test_get_torch_index_url.sh" +sh "$TESTS_DIR/sh/test_mac_intel_compat.sh" echo "" echo "=== Python tests ===" python -m pytest "$TESTS_DIR/python/test_install_python_stack.py" -v python -m pytest "$TESTS_DIR/python/test_cross_platform_parity.py" -v +python -m pytest "$TESTS_DIR/python/test_no_torch_filtering.py" -v +python -m pytest "$TESTS_DIR/python/test_studio_import_no_torch.py" -v echo "" echo "All tests passed." diff --git a/tests/sh/test_mac_intel_compat.sh b/tests/sh/test_mac_intel_compat.sh new file mode 100644 index 0000000000..d8848fd019 --- /dev/null +++ b/tests/sh/test_mac_intel_compat.sh @@ -0,0 +1,582 @@ +#!/bin/bash +# End-to-end sandbox tests for Mac Intel compatibility and UNSLOTH_NO_TORCH propagation. +# Tests version_ge, arch detection (existing), plus E2E venv creation, torch skip +# via a mock uv shim, and UNSLOTH_NO_TORCH env propagation in install.sh. +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SH="$SCRIPT_DIR/../../install.sh" +PASS=0 +FAIL=0 + +assert_eq() { + _label="$1"; _expected="$2"; _actual="$3" + if [ "$_actual" = "$_expected" ]; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected '$_expected', got '$_actual')" + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + _label="$1"; _haystack="$2"; _needle="$3" + if echo "$_haystack" | grep -qF "$_needle"; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected to find '$_needle')" + FAIL=$((FAIL + 1)) + fi +} + +assert_not_contains() { + _label="$1"; _haystack="$2"; _needle="$3" + if echo "$_haystack" | grep -qF "$_needle"; then + echo " FAIL: $_label (found '$_needle' but should not)" + FAIL=$((FAIL + 1)) + else + echo " PASS: $_label" + PASS=$((PASS + 1)) + fi +} + +# ── Extract version_ge function from install.sh ── +_VGE_FILE=$(mktemp) +sed -n '/^version_ge()/,/^}/p' "$INSTALL_SH" > "$_VGE_FILE" + +echo "=== version_ge ===" + +# Basic comparisons +_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.13' '3.12' && echo pass || echo fail") +assert_eq "3.13 >= 3.12" "pass" "$_result" + +_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.12' '3.13' && echo pass || echo fail") +assert_eq "3.12 >= 3.13" "fail" "$_result" + +_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.13' '3.13' && echo pass || echo fail") +assert_eq "3.13 >= 3.13 (equal)" "pass" "$_result" + +# Patch versions +_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.13.8' '3.13' && echo pass || echo fail") +assert_eq "3.13.8 >= 3.13 (patch > implicit 0)" "pass" "$_result" + +_result=$(bash -c ". '$_VGE_FILE'; version_ge '3.12.0' '3.13.0' && echo pass || echo fail") +assert_eq "3.12.0 >= 3.13.0 (minor less)" "fail" "$_result" + +# UV_MIN_VERSION edge cases +_result=$(bash -c ". '$_VGE_FILE'; version_ge '0.7.14' '0.7.14' && echo pass || echo fail") +assert_eq "0.7.14 >= 0.7.14 (exact UV_MIN_VERSION)" "pass" "$_result" + +_result=$(bash -c ". '$_VGE_FILE'; version_ge '0.7.13' '0.7.14' && echo pass || echo fail") +assert_eq "0.7.13 >= 0.7.14 (below minimum)" "fail" "$_result" + +_result=$(bash -c ". '$_VGE_FILE'; version_ge '0.11.1' '0.7.14' && echo pass || echo fail") +assert_eq "0.11.1 >= 0.7.14 (well above)" "pass" "$_result" + +# Major jump +_result=$(bash -c ". '$_VGE_FILE'; version_ge '1.0' '0.99.99' && echo pass || echo fail") +assert_eq "1.0 >= 0.99.99 (major jump)" "pass" "$_result" + +rm -f "$_VGE_FILE" + +echo "" +echo "=== Architecture detection + PYTHON_VERSION ===" + +# Self-contained arch detection snippet matching install.sh logic +_ARCH_SNIPPET=$(mktemp) +cat > "$_ARCH_SNIPPET" << 'SNIPPET' +OS="linux" +if [ "$(uname)" = "Darwin" ]; then + OS="macos" +fi +_ARCH=$(uname -m) +MAC_INTEL=false +if [ "$OS" = "macos" ] && [ "$_ARCH" = "x86_64" ]; then + MAC_INTEL=true +fi +_USER_PYTHON="" +if [ -n "$_USER_PYTHON" ]; then + PYTHON_VERSION="$_USER_PYTHON" +elif [ "$MAC_INTEL" = true ]; then + PYTHON_VERSION="3.12" +else + PYTHON_VERSION="3.13" +fi +echo "$OS $MAC_INTEL $PYTHON_VERSION" +SNIPPET + +# Test: Darwin x86_64 -> macos true 3.12 +_result=$(bash -c ' +uname() { + case "$1" in + -m) echo "x86_64" ;; + *) echo "Darwin" ;; + esac +} +export -f uname +'"source '$_ARCH_SNIPPET'") +assert_eq "Darwin x86_64 -> macos true 3.12" "macos true 3.12" "$_result" + +# Test: Darwin arm64 -> macos false 3.13 +_result=$(bash -c ' +uname() { + case "$1" in + -m) echo "arm64" ;; + *) echo "Darwin" ;; + esac +} +export -f uname +'"source '$_ARCH_SNIPPET'") +assert_eq "Darwin arm64 -> macos false 3.13" "macos false 3.13" "$_result" + +# Test: Linux x86_64 -> linux false 3.13 +_result=$(bash -c ' +uname() { + case "$1" in + -m) echo "x86_64" ;; + *) echo "Linux" ;; + esac +} +export -f uname +'"source '$_ARCH_SNIPPET'") +assert_eq "Linux x86_64 -> linux false 3.13" "linux false 3.13" "$_result" + +# Test: Linux aarch64 -> linux false 3.13 +_result=$(bash -c ' +uname() { + case "$1" in + -m) echo "aarch64" ;; + *) echo "Linux" ;; + esac +} +export -f uname +'"source '$_ARCH_SNIPPET'") +assert_eq "Linux aarch64 -> linux false 3.13" "linux false 3.13" "$_result" + +rm -f "$_ARCH_SNIPPET" + +echo "" +echo "=== get_torch_index_url on Darwin ===" + +# Extract get_torch_index_url and replace hardcoded nvidia-smi path +_FUNC_FILE=$(mktemp) +_FAKE_SMI_DIR=$(mktemp -d) +sed -n '/^get_torch_index_url()/,/^}/p' "$INSTALL_SH" \ + | sed "s|/usr/bin/nvidia-smi|$_FAKE_SMI_DIR/nvidia-smi-absent|g" \ + > "$_FUNC_FILE" + +# Build a minimal tools directory +_TOOLS_DIR=$(mktemp -d) +for _cmd in grep sed head sh bash cat; do + _real=$(command -v "$_cmd" 2>/dev/null || true) + [ -n "$_real" ] && ln -sf "$_real" "$_TOOLS_DIR/$_cmd" +done + +# Create a mock uname that returns Darwin +_MOCK_UNAME_DIR=$(mktemp -d) +cat > "$_MOCK_UNAME_DIR/uname" << 'MOCK_UNAME' +#!/bin/sh +case "$1" in + -s) echo "Darwin" ;; + -m) echo "arm64" ;; + *) echo "Darwin" ;; +esac +MOCK_UNAME +chmod +x "$_MOCK_UNAME_DIR/uname" + +# Mock nvidia-smi that returns CUDA version (to prove macOS ignores it) +_GPU_DIR=$(mktemp -d) +cat > "$_GPU_DIR/nvidia-smi" << 'MOCK_SMI' +#!/bin/sh +cat <<'SMI_OUT' ++-----------------------------------------------------------------------------------------+ +| NVIDIA-SMI 550.54.15 Driver Version: 550.54.15 CUDA Version: 12.6 | ++-----------------------------------------------------------------------------------------+ +SMI_OUT +MOCK_SMI +chmod +x "$_GPU_DIR/nvidia-smi" + +# Test: Darwin always returns cpu (even with nvidia-smi present) +_result=$(PATH="$_GPU_DIR:$_MOCK_UNAME_DIR:$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null) +assert_eq "Darwin -> cpu (even with nvidia-smi)" "https://download.pytorch.org/whl/cpu" "$_result" + +# Test: Darwin without nvidia-smi also returns cpu +_result=$(PATH="$_MOCK_UNAME_DIR:$_TOOLS_DIR" bash -c ". '$_FUNC_FILE'; get_torch_index_url" 2>/dev/null) +assert_eq "Darwin -> cpu (no nvidia-smi)" "https://download.pytorch.org/whl/cpu" "$_result" + +rm -f "$_FUNC_FILE" +rm -rf "$_FAKE_SMI_DIR" "$_TOOLS_DIR" "$_MOCK_UNAME_DIR" "$_GPU_DIR" + +echo "" +echo "=== UNSLOTH_NO_TORCH propagation ===" + +# Verify UNSLOTH_NO_TORCH is passed to setup.sh in BOTH the --local and non-local branches. +_local_count=$(grep -c 'UNSLOTH_NO_TORCH=' "$INSTALL_SH" | head -1) +if [ "$_local_count" -ge 2 ]; then + echo " PASS: UNSLOTH_NO_TORCH appears in >= 2 setup.sh invocations ($_local_count found)" + PASS=$((PASS + 1)) +else + echo " FAIL: UNSLOTH_NO_TORCH should appear in >= 2 setup.sh invocations (found $_local_count)" + FAIL=$((FAIL + 1)) +fi + +# Verify the value passed is "$SKIP_TORCH" (the unified variable, not MAC_INTEL) +_skip_torch_count=$(grep 'UNSLOTH_NO_TORCH="\$SKIP_TORCH"' "$INSTALL_SH" | wc -l) +if [ "$_skip_torch_count" -ge 2 ]; then + echo " PASS: UNSLOTH_NO_TORCH=\"\$SKIP_TORCH\" in both branches ($_skip_torch_count found)" + PASS=$((PASS + 1)) +else + echo " FAIL: UNSLOTH_NO_TORCH=\"\$SKIP_TORCH\" should appear in >= 2 branches (found $_skip_torch_count)" + FAIL=$((FAIL + 1)) +fi + +# Verify MAC_INTEL is set to true when Intel Mac is detected +_mac_intel_set=$(grep -c 'MAC_INTEL=true' "$INSTALL_SH") +if [ "$_mac_intel_set" -ge 1 ]; then + echo " PASS: MAC_INTEL=true is set in install.sh" + PASS=$((PASS + 1)) +else + echo " FAIL: MAC_INTEL=true not found in install.sh" + FAIL=$((FAIL + 1)) +fi + +# Verify the PyTorch skip message exists (now covers both --no-torch and Intel Mac) +if grep -q 'Skipping PyTorch' "$INSTALL_SH"; then + echo " PASS: PyTorch skip message found" + PASS=$((PASS + 1)) +else + echo " FAIL: PyTorch skip message not found" + FAIL=$((FAIL + 1)) +fi + +# Verify SKIP_TORCH unified variable exists +if grep -q 'SKIP_TORCH=true' "$INSTALL_SH"; then + echo " PASS: SKIP_TORCH=true assignment found" + PASS=$((PASS + 1)) +else + echo " FAIL: SKIP_TORCH=true not found in install.sh" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "=== E2E: venv creation at Python 3.12 (simulated Intel Mac) ===" + +# Actually create a uv venv at Python 3.12 to verify the path works +if command -v uv >/dev/null 2>&1; then + _VENV_DIR=$(mktemp -d) + _uv_result=$(uv venv "$_VENV_DIR/test_venv" --python 3.12 2>&1) && _uv_rc=0 || _uv_rc=$? + if [ "$_uv_rc" -eq 0 ]; then + echo " PASS: uv venv created at Python 3.12" + PASS=$((PASS + 1)) + + # Verify Python version inside the venv + _py_ver=$("$_VENV_DIR/test_venv/bin/python" -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") + assert_eq "venv Python is 3.12" "3.12" "$_py_ver" + + # Verify torch is NOT available (fresh venv has no torch) + if "$_VENV_DIR/test_venv/bin/python" -c "import torch" 2>/dev/null; then + echo " FAIL: torch should NOT be importable in fresh 3.12 venv" + FAIL=$((FAIL + 1)) + else + echo " PASS: torch not importable in fresh 3.12 venv (expected for Intel Mac)" + PASS=$((PASS + 1)) + fi + else + echo " SKIP: Could not create Python 3.12 venv (python 3.12 not available)" + fi + rm -rf "$_VENV_DIR" +else + echo " SKIP: uv not available, cannot test venv creation" +fi + +echo "" +echo "=== E2E: torch install skipped when SKIP_TORCH=true (mock uv shim) ===" + +# Create a mock uv that logs all calls instead of running them +_MOCK_UV_DIR=$(mktemp -d) +_UV_LOG="$_MOCK_UV_DIR/uv_calls.log" +touch "$_UV_LOG" +cat > "$_MOCK_UV_DIR/uv" << MOCK_UV_EOF +#!/bin/sh +echo "UV_CALL: \$*" >> "$_UV_LOG" +MOCK_UV_EOF +chmod +x "$_MOCK_UV_DIR/uv" + +# Simulates the torch install decision from install.sh using SKIP_TORCH +_TORCH_BLOCK=$(mktemp) +cat > "$_TORCH_BLOCK" << 'TORCH_EOF' +# Simulates the torch install decision from install.sh +TORCH_INDEX_URL="https://download.pytorch.org/whl/cpu" +_VENV_PY="/fake/python" +if [ "$SKIP_TORCH" = true ]; then + echo "==> Skipping PyTorch (--no-torch or Intel Mac x86_64)." +else + echo "==> Installing PyTorch ($TORCH_INDEX_URL)..." + uv pip install --python "$_VENV_PY" "torch>=2.4,<2.11.0" torchvision torchaudio \ + --index-url "$TORCH_INDEX_URL" +fi +TORCH_EOF + +# Test: SKIP_TORCH=true -> torch install should be SKIPPED (no uv calls) +> "$_UV_LOG" # clear log +_torch_output=$(SKIP_TORCH=true PATH="$_MOCK_UV_DIR:$PATH" bash "$_TORCH_BLOCK" 2>&1) +assert_contains "SKIP_TORCH=true prints skip message" "$_torch_output" "Skipping PyTorch" +if [ -s "$_UV_LOG" ]; then + echo " FAIL: uv was called when SKIP_TORCH=true (should be skipped)" + echo " Log: $(cat "$_UV_LOG")" + FAIL=$((FAIL + 1)) +else + echo " PASS: no uv pip install torch when SKIP_TORCH=true" + PASS=$((PASS + 1)) +fi + +# Test: SKIP_TORCH=false -> torch install should EXECUTE (uv called with torch) +> "$_UV_LOG" # clear log +_torch_output=$(SKIP_TORCH=false PATH="$_MOCK_UV_DIR:$PATH" bash "$_TORCH_BLOCK" 2>&1) +assert_contains "SKIP_TORCH=false prints install message" "$_torch_output" "Installing PyTorch" +if grep -q "torch" "$_UV_LOG"; then + echo " PASS: uv pip install torch called when SKIP_TORCH=false" + PASS=$((PASS + 1)) +else + echo " FAIL: uv pip install torch NOT called when SKIP_TORCH=false" + FAIL=$((FAIL + 1)) +fi + +rm -f "$_TORCH_BLOCK" +rm -rf "$_MOCK_UV_DIR" + +echo "" +echo "=== E2E: UNSLOTH_NO_TORCH env propagation (dynamic test) ===" + +# Simulates the setup.sh invocation using SKIP_TORCH +_ENV_BLOCK=$(mktemp) +cat > "$_ENV_BLOCK" << 'ENV_EOF' +# Simulates the setup.sh invocation block from install.sh +PACKAGE_NAME="unsloth" +_REPO_ROOT="/fake/repo" +SETUP_SH="/fake/setup.sh" + +if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + SKIP_STUDIO_BASE=1 \ + STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \ + STUDIO_LOCAL_INSTALL=1 \ + STUDIO_LOCAL_REPO="$_REPO_ROOT" \ + UNSLOTH_NO_TORCH="$SKIP_TORCH" \ + env | grep "^UNSLOTH_NO_TORCH=" +else + SKIP_STUDIO_BASE=1 \ + STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \ + UNSLOTH_NO_TORCH="$SKIP_TORCH" \ + env | grep "^UNSLOTH_NO_TORCH=" +fi +ENV_EOF + +# Test: SKIP_TORCH=true -> UNSLOTH_NO_TORCH=true in env +_env_result=$(SKIP_TORCH=true STUDIO_LOCAL_INSTALL=false bash "$_ENV_BLOCK" 2>&1) +assert_eq "non-local: UNSLOTH_NO_TORCH=true when SKIP_TORCH=true" "UNSLOTH_NO_TORCH=true" "$_env_result" + +# Test: SKIP_TORCH=false -> UNSLOTH_NO_TORCH=false in env +_env_result=$(SKIP_TORCH=false STUDIO_LOCAL_INSTALL=false bash "$_ENV_BLOCK" 2>&1) +assert_eq "non-local: UNSLOTH_NO_TORCH=false when SKIP_TORCH=false" "UNSLOTH_NO_TORCH=false" "$_env_result" + +# Test: local install path also propagates +_env_result=$(SKIP_TORCH=true STUDIO_LOCAL_INSTALL=true bash "$_ENV_BLOCK" 2>&1) +assert_eq "local: UNSLOTH_NO_TORCH=true when SKIP_TORCH=true" "UNSLOTH_NO_TORCH=true" "$_env_result" + +_env_result=$(SKIP_TORCH=false STUDIO_LOCAL_INSTALL=true bash "$_ENV_BLOCK" 2>&1) +assert_eq "local: UNSLOTH_NO_TORCH=false when SKIP_TORCH=false" "UNSLOTH_NO_TORCH=false" "$_env_result" + +rm -f "$_ENV_BLOCK" + +echo "" +echo "=== --python override flag ===" + +# Test: flag parsing extracts version correctly +_PARSE_BLOCK=$(mktemp) +cat > "$_PARSE_BLOCK" << 'PARSE_EOF' +_USER_PYTHON="" +_next_is_python=false +_next_is_package=false +STUDIO_LOCAL_INSTALL=false +PACKAGE_NAME="unsloth" +for arg in "$@"; do + if [ "$_next_is_package" = true ]; then PACKAGE_NAME="$arg"; _next_is_package=false; continue; fi + if [ "$_next_is_python" = true ]; then _USER_PYTHON="$arg"; _next_is_python=false; continue; fi + case "$arg" in + --local) STUDIO_LOCAL_INSTALL=true ;; + --package) _next_is_package=true ;; + --python) _next_is_python=true ;; + esac +done +if [ "$_next_is_python" = true ]; then echo "ERROR"; exit 1; fi +echo "$_USER_PYTHON" +PARSE_EOF + +_result=$(bash "$_PARSE_BLOCK" --python 3.12) +assert_eq "--python 3.12 parsed" "3.12" "$_result" + +_result=$(bash "$_PARSE_BLOCK" --local --python 3.11) +assert_eq "--local --python 3.11 parsed" "3.11" "$_result" + +_result=$(bash "$_PARSE_BLOCK" --python 3.12 --local --package foo) +assert_eq "--python with --local --package" "3.12" "$_result" + +_result=$(bash "$_PARSE_BLOCK" 2>&1) # no --python +assert_eq "no --python -> empty" "" "$_result" + +_rc=0 +bash "$_PARSE_BLOCK" --python >/dev/null 2>&1 || _rc=$? +assert_eq "--python without arg -> error" "1" "$_rc" + +rm -f "$_PARSE_BLOCK" + +# Test: --python overrides auto-detected version in PYTHON_VERSION resolution +_RESOLVE_BLOCK=$(mktemp) +cat > "$_RESOLVE_BLOCK" << 'RESOLVE_EOF' +_USER_PYTHON="$1" +MAC_INTEL="$2" +if [ -n "$_USER_PYTHON" ]; then + PYTHON_VERSION="$_USER_PYTHON" +elif [ "$MAC_INTEL" = true ]; then + PYTHON_VERSION="3.12" +else + PYTHON_VERSION="3.13" +fi +echo "$PYTHON_VERSION" +RESOLVE_EOF + +_result=$(bash "$_RESOLVE_BLOCK" "3.11" "true") +assert_eq "--python 3.11 overrides Intel Mac 3.12" "3.11" "$_result" + +_result=$(bash "$_RESOLVE_BLOCK" "3.12" "false") +assert_eq "--python 3.12 overrides default 3.13" "3.12" "$_result" + +_result=$(bash "$_RESOLVE_BLOCK" "" "true") +assert_eq "no override -> Intel Mac gets 3.12" "3.12" "$_result" + +_result=$(bash "$_RESOLVE_BLOCK" "" "false") +assert_eq "no override -> non-Intel gets 3.13" "3.13" "$_result" + +rm -f "$_RESOLVE_BLOCK" + +# Test: --python flag exists in install.sh +if grep -q '\-\-python)' "$INSTALL_SH"; then + echo " PASS: --python case exists in install.sh" + PASS=$((PASS + 1)) +else + echo " FAIL: --python case not found in install.sh" + FAIL=$((FAIL + 1)) +fi + +# Test: _USER_PYTHON guards exist for stale-venv and 3.13.8 checks +_user_py_guards=$(grep -c '_USER_PYTHON' "$INSTALL_SH") +if [ "$_user_py_guards" -ge 4 ]; then + echo " PASS: _USER_PYTHON referenced >= 4 times in install.sh (flag + resolution + guards)" + PASS=$((PASS + 1)) +else + echo " FAIL: _USER_PYTHON should appear >= 4 times (found $_user_py_guards)" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "=== --no-torch flag parsing ===" + +# Test: --no-torch sets _NO_TORCH_FLAG=true +_FLAG_SNIPPET=$(mktemp) +cat > "$_FLAG_SNIPPET" << 'SNIPPET' +_NO_TORCH_FLAG=false +_next_is_package=false +STUDIO_LOCAL_INSTALL=false +PACKAGE_NAME="unsloth" +for arg in "$@"; do + if [ "$_next_is_package" = true ]; then + PACKAGE_NAME="$arg" + _next_is_package=false + continue + fi + case "$arg" in + --local) STUDIO_LOCAL_INSTALL=true ;; + --package) _next_is_package=true ;; + --no-torch) _NO_TORCH_FLAG=true ;; + esac +done +echo "$_NO_TORCH_FLAG" +SNIPPET + +_result=$(bash "$_FLAG_SNIPPET" --no-torch) +assert_eq "--no-torch sets flag to true" "true" "$_result" + +_result=$(bash "$_FLAG_SNIPPET") +assert_eq "no flags -> flag is false" "false" "$_result" + +_result=$(bash "$_FLAG_SNIPPET" --local --no-torch) +assert_eq "--local --no-torch both work" "true" "$_result" + +_result=$(bash "$_FLAG_SNIPPET" --no-torch --package custom-pkg) +assert_eq "--no-torch with --package works" "true" "$_result" + +rm -f "$_FLAG_SNIPPET" + +echo "" +echo "=== SKIP_TORCH unification ===" + +# Test: SKIP_TORCH is set to true when --no-torch flag is set (even without MAC_INTEL) +_SKIP_SNIPPET=$(mktemp) +cat > "$_SKIP_SNIPPET" << 'SNIPPET' +MAC_INTEL=false +_NO_TORCH_FLAG=$1 +SKIP_TORCH=false +if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then + SKIP_TORCH=true +fi +echo "$SKIP_TORCH" +SNIPPET + +_result=$(bash "$_SKIP_SNIPPET" true) +assert_eq "--no-torch flag alone sets SKIP_TORCH=true" "true" "$_result" + +_result=$(bash "$_SKIP_SNIPPET" false) +assert_eq "no flag, no MAC_INTEL -> SKIP_TORCH=false" "false" "$_result" + +# Test: MAC_INTEL=true alone also sets SKIP_TORCH=true +_SKIP_SNIPPET2=$(mktemp) +cat > "$_SKIP_SNIPPET2" << 'SNIPPET' +MAC_INTEL=true +_NO_TORCH_FLAG=false +SKIP_TORCH=false +if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then + SKIP_TORCH=true +fi +echo "$SKIP_TORCH" +SNIPPET + +_result=$(bash "$_SKIP_SNIPPET2") +assert_eq "MAC_INTEL=true alone sets SKIP_TORCH=true" "true" "$_result" + +rm -f "$_SKIP_SNIPPET" "$_SKIP_SNIPPET2" + +echo "" +echo "=== CPU hint printing ===" + +# Verify the CPU hint is present in install.sh source +if grep -q 'No NVIDIA GPU detected' "$INSTALL_SH"; then + echo " PASS: CPU hint message found in install.sh" + PASS=$((PASS + 1)) +else + echo " FAIL: CPU hint message not found in install.sh" + FAIL=$((FAIL + 1)) +fi + +if grep -q '\-\-no-torch' "$INSTALL_SH"; then + echo " PASS: --no-torch appears in install.sh" + PASS=$((PASS + 1)) +else + echo " FAIL: --no-torch not found in install.sh" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] || exit 1 From 3c9f0ed14954d9b803cb58b1df44d1388bb2acb0 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 02:38:11 -0700 Subject: [PATCH 47/94] fix: use unsloth[huggingfacenotorch] instead of --no-deps in no-torch mode (#4647) The previous --no-deps approach skipped ALL dependencies, not just torch. This left safetensors, transformers, datasets, accelerate, etc. missing, causing PackageNotFoundError at runtime. Fix: in no-torch mode, install unsloth[huggingfacenotorch] (which pulls all runtime deps except torch), then install unsloth-zoo with --no-deps (since zoo's published metadata still declares torch as a hard dep). This gives a working no-torch environment with all non-torch packages. Applied to all three installer files: install.sh, install.ps1, and studio/install_python_stack.py. --- install.ps1 | 26 ++++++++++++++++++------ install.sh | 36 +++++++++++++++++++++++----------- studio/install_python_stack.py | 17 +++++++++++----- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/install.ps1 b/install.ps1 index 41e8efa5c3..83dcf866e5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -620,8 +620,14 @@ shell.Run cmd, 0, False # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state # in the new venv location, while preserving existing torch/CUDA Write-Host "==> Upgrading unsloth in migrated environment..." - $noDepsArg = if ($SkipTorch) { "--no-deps" } else { $null } - uv pip install --python $VenvPython $noDepsArg --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo + if ($SkipTorch) { + # No-torch: install runtime deps via [huggingfacenotorch] extras, + # then unsloth-zoo with --no-deps to avoid pulling torch. + uv pip install --python $VenvPython --reinstall-package unsloth "unsloth[huggingfacenotorch]>=2026.3.14" + uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo unsloth-zoo + } else { + uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo + } if ($StudioLocalInstall) { Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps @@ -639,13 +645,21 @@ shell.Run cmd, 0, False } Write-Host "==> Installing unsloth (this may take a few minutes)..." - $noDepsArg = if ($SkipTorch) { "--no-deps" } else { $null } - if ($StudioLocalInstall) { - uv pip install --python $VenvPython $noDepsArg --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo + if ($SkipTorch) { + # No-torch: install runtime deps via [huggingfacenotorch] extras, + # then unsloth-zoo with --no-deps to avoid pulling torch. + uv pip install --python $VenvPython --upgrade-package unsloth "unsloth[huggingfacenotorch]>=2026.3.14" + uv pip install --python $VenvPython --no-deps --upgrade-package unsloth-zoo unsloth-zoo + if ($StudioLocalInstall) { + Write-Host "==> Overlaying local repo (editable)..." + uv pip install --python $VenvPython -e $RepoRoot --no-deps + } + } elseif ($StudioLocalInstall) { + uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } else { - uv pip install --python $VenvPython $noDepsArg --upgrade-package unsloth "$PackageName" + uv pip install --python $VenvPython --upgrade-package unsloth "$PackageName" } } else { # Fallback: GPU detection failed to produce a URL -- let uv resolve torch diff --git a/install.sh b/install.sh index 2024622603..4afa514527 100755 --- a/install.sh +++ b/install.sh @@ -863,13 +863,19 @@ if [ "$_MIGRATED" = true ]; then # Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state # in the new venv location, while preserving existing torch/CUDA echo "==> Upgrading unsloth in migrated environment..." - _no_deps_arg="" if [ "$SKIP_TORCH" = true ]; then - _no_deps_arg="--no-deps" + # No-torch: install runtime deps via [huggingfacenotorch] extras, + # then unsloth-zoo with --no-deps to avoid pulling torch. + uv pip install --python "$_VENV_PY" \ + --reinstall-package unsloth \ + "unsloth[huggingfacenotorch]>=2026.3.14" + uv pip install --python "$_VENV_PY" --no-deps \ + --reinstall-package unsloth-zoo unsloth-zoo + else + uv pip install --python "$_VENV_PY" \ + --reinstall-package unsloth --reinstall-package unsloth-zoo \ + "unsloth>=2026.3.14" unsloth-zoo fi - uv pip install --python "$_VENV_PY" $_no_deps_arg \ - --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.3.14" unsloth-zoo if [ "$STUDIO_LOCAL_INSTALL" = true ]; then echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps @@ -885,17 +891,25 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi # Fresh: Step 2 - install unsloth, preserving pre-installed torch echo "==> Installing unsloth (this may take a few minutes)..." - _no_deps_arg="" if [ "$SKIP_TORCH" = true ]; then - _no_deps_arg="--no-deps" - fi - if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - uv pip install --python "$_VENV_PY" $_no_deps_arg \ + # No-torch: install runtime deps via [huggingfacenotorch] extras, + # then unsloth-zoo with --no-deps to avoid pulling torch. + uv pip install --python "$_VENV_PY" \ + --upgrade-package unsloth \ + "unsloth[huggingfacenotorch]>=2026.3.14" + uv pip install --python "$_VENV_PY" --no-deps \ + --upgrade-package unsloth-zoo unsloth-zoo + if [ "$STUDIO_LOCAL_INSTALL" = true ]; then + echo "==> Overlaying local repo (editable)..." + uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps + fi + elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then + uv pip install --python "$_VENV_PY" \ --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else - uv pip install --python "$_VENV_PY" $_no_deps_arg \ + uv pip install --python "$_VENV_PY" \ --upgrade-package unsloth "$PACKAGE_NAME" fi else diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index 2402d7c39c..bcd17c4d88 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -440,17 +440,24 @@ def install_python_stack() -> int: if skip_base: print(_green(f"✅ {package_name} already installed — skipping base packages")) elif NO_TORCH: - # No-torch mode: install unsloth + unsloth-zoo without torch deps + # No-torch mode: install runtime deps via [huggingfacenotorch] extras + # (safetensors, transformers, datasets, etc.), then unsloth-zoo with + # --no-deps to avoid pulling torch. _progress("base packages (no torch)") pip_install( - "Updating base packages (no-torch mode)", + "Installing unsloth runtime deps (no-torch mode)", + "--no-cache-dir", + "--upgrade-package", + "unsloth", + "unsloth[huggingfacenotorch]>=2026.3.14", + ) + pip_install( + "Installing unsloth-zoo (no-torch mode)", "--no-cache-dir", "--no-deps", "--upgrade-package", - "unsloth", - "--upgrade-package", "unsloth-zoo", - req = REQ_ROOT / "base.txt", + "unsloth-zoo", ) if local_repo: pip_install( From 5c9a22b8166977acd072f224f428bcf072819cc3 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 02:53:21 -0700 Subject: [PATCH 48/94] Fix Gemma3N audio training stride assertion with non-reentrant checkpointing (#4629) * Fix Gemma3N audio training stride assertion with non-reentrant checkpointing Gemma3N audio conformer processes variable-length audio tensors that cause stride mismatches in AOT autograd compiled backward when non-reentrant gradient checkpointing is used. The error manifests as: AssertionError: expected size 2==2, stride 1928==1936 at dim=0 This happens because the audio conformer's conv/norm layers produce tensors whose strides vary with audio clip duration, but AOT autograd traces the backward graph assuming fixed strides from the first batch. The notebook sets gradient_checkpointing_kwargs={"use_reentrant": False} and TRL 0.27.0+ also forces this. Both override Unsloth's own use_reentrant=True set during prepare_model_for_training. Fix: intercept gradient_checkpointing_enable on Gemma3N models to always force use_reentrant=True, regardless of what the notebook or TRL passes. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/models/vision.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/unsloth/models/vision.py b/unsloth/models/vision.py index a8adba99e7..f558aa3f00 100644 --- a/unsloth/models/vision.py +++ b/unsloth/models/vision.py @@ -1368,6 +1368,24 @@ class FastBaseModel: patch_modules_to_save = True, ) + # Gemma3N audio conformer processes variable-length audio tensors + # that cause stride mismatches in AOT autograd compiled backward + # when non-reentrant checkpointing is used. The notebook or TRL + # may override gradient_checkpointing_kwargs with use_reentrant=False + # after this point, so we intercept gradient_checkpointing_enable + # to always force use_reentrant=True for Gemma3N. + _model_type = getattr(getattr(model, "config", None), "model_type", "") or "" + if "gemma3n" in _model_type.lower(): + _original_gc_enable = model.gradient_checkpointing_enable + + def _gc_enable_reentrant(**kwargs): + gc_kwargs = kwargs.get("gradient_checkpointing_kwargs", {}) or {} + gc_kwargs["use_reentrant"] = True + kwargs["gradient_checkpointing_kwargs"] = gc_kwargs + return _original_gc_enable(**kwargs) + + model.gradient_checkpointing_enable = _gc_enable_reentrant + from transformers.trainer import Trainer if ( From 19298a0b4137dc2b8dc13cfac5a5143607bb1de1 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Fri, 27 Mar 2026 02:56:34 -0700 Subject: [PATCH 49/94] Update Uninstall instructions.md --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c70b1b9c5f..ba15aa9862 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,19 @@ unsloth studio -H 0.0.0.0 -p 8888 ``` #### Uninstall -You can uninstall Unsloth Studio by deleting its folder. For example, run `rm -rf ~/.unsloth/studio`. Only use `rm -rf ~/.unsloth/` if you want to remove all Unsloth files, not just Studio. +You can uninstall Unsloth Studio by deleting its install folder usually located under `$HOME/.unsloth/studio` on Mac/Linux/WSL and `%USERPROFILE%\.unsloth\studio` on Windows. Using the `rm -rf` commands will **delete everything**, including your history, cache: + +* ​ **MacOS, WSL, Linux:** `rm -rf ~/.unsloth/studio` +* ​ **Windows (PowerShell):** `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"` + +For more info, [see our docs](https://unsloth.ai/docs/new/studio/install#uninstall). + +##### Deleting model files + +You can delete old model files either from the bin icon in model search or by removing the relevant cached model folder from the default Hugging Face cache directory. By default, HF uses: + +* ​ **MacOS, Linux, WSL:** `~/.cache/huggingface/hub/` +* ​ **Windows:** `%USERPROFILE%\.cache\huggingface\hub\` ### Unsloth Core (code-based) #### Linux, WSL: From 0ffac92cf43e2bbd20742694937ca5d1c7932ee8 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Fri, 27 Mar 2026 03:04:07 -0700 Subject: [PATCH 50/94] Update Install instructions.md --- README.md | 119 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 62 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index ba15aa9862..b392ed145f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,67 @@ docker run -d -e JUPYTER_PASSWORD="mypassword" \ unsloth/unsloth ``` +#### Developer, Nightly, Uninstall +To see developer, nightly and uninstallation etc. instructions, see [advanced installation](#-advanced-installation). + +### Unsloth Core (code-based) +#### Linux, WSL: +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +uv venv unsloth_env --python 3.13 +source unsloth_env/bin/activate +uv pip install unsloth --torch-backend=auto +``` +#### Windows: +```powershell +winget install -e --id Python.Python.3.13 +winget install --id=astral-sh.uv -e +uv venv unsloth_env --python 3.13 +.\unsloth_env\Scripts\activate +uv pip install unsloth --torch-backend=auto +``` +For Windows, `pip install unsloth` works only if you have PyTorch installed. Read our [Windows Guide](https://unsloth.ai/docs/get-started/install/windows-installation). +You can use the same Docker image as Unsloth Studio. + +#### AMD, Intel: +For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
+To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). + +## ✨ Free Notebooks + +Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model. + +| Model | Free Notebooks | Performance | Memory use | +|-----------|---------|--------|----------| +| **Qwen3.5 (4B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision.ipynb) | 1.5x faster | 60% less | +| **gpt-oss (20B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-Fine-tuning.ipynb) | 2x faster | 70% less | +| **Qwen3.5 GSPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision_GRPO.ipynb) | 2x faster | 70% less | +| **gpt-oss (20B): GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-GRPO.ipynb) | 2x faster | 80% less | +| **Qwen3: Advanced GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_(4B)-GRPO.ipynb) | 2x faster | 70% less | +| **Gemma 3 (4B) Vision** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma3_(4B)-Vision.ipynb) | 1.7x faster | 60% less | +| **embeddinggemma (300M)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/EmbeddingGemma_(300M).ipynb) | 2x faster | 20% less | +| **Mistral Ministral 3 (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Ministral_3_VL_(3B)_Vision.ipynb) | 1.5x faster | 60% less | +| **Llama 3.1 (8B) Alpaca** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.1_(8B)-Alpaca.ipynb) | 2x faster | 70% less | +| **Llama 3.2 Conversational** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.2_(1B_and_3B)-Conversational.ipynb) | 2x faster | 70% less | +| **Orpheus-TTS (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Orpheus_(3B)-TTS.ipynb) | 1.5x faster | 50% less | + +- See all our notebooks for: [Kaggle](https://github.com/unslothai/notebooks?tab=readme-ov-file#-kaggle-notebooks), [GRPO](https://unsloth.ai/docs/get-started/unsloth-notebooks#grpo-reasoning-rl-notebooks), [TTS](https://unsloth.ai/docs/get-started/unsloth-notebooks#text-to-speech-tts-notebooks), [embedding](https://unsloth.ai/docs/new/embedding-finetuning) & [Vision](https://unsloth.ai/docs/get-started/unsloth-notebooks#vision-multimodal-notebooks) +- See [all our models](https://unsloth.ai/docs/get-started/unsloth-model-catalog) and [all our notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks) +- See detailed documentation for Unsloth [here](https://unsloth.ai/docs) + +## 🦥 Unsloth News +- **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio) +- **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune) +- Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe) +- **Embedding models**: Unsloth now supports ~1.8-3.3x faster embedding fine-tuning. [Blog](https://unsloth.ai/docs/new/embedding-finetuning) • [Notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks#embedding-models) +- New **7x longer context RL** vs. all other setups, via our new batching algorithms. [Blog](https://unsloth.ai/docs/new/grpo-long-context) +- New RoPE & MLP **Triton Kernels** & **Padding Free + Packing**: 3x faster training & 30% less VRAM. [Blog](https://unsloth.ai/docs/new/3x-faster-training-packing) +- **500K Context**: Training a 20B model with >500K context is now possible on an 80GB GPU. [Blog](https://unsloth.ai/docs/blog/500k-context-length-fine-tuning) +- **FP8 & Vision RL**: You can now do FP8 & VLM GRPO on consumer GPUs. [FP8 Blog](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) • [Vision RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/vision-reinforcement-learning-vlm-rl) +- **gpt-oss** by OpenAI: Read our [RL blog](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune/gpt-oss-reinforcement-learning), [Flex Attention](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune/long-context-gpt-oss-training) blog and [Guide](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune). + +## 📥 Advanced Installation +The below advanced instructions are for Unsloth Studio. For Unsloth Core advanced installation, [view our docs](https://unsloth.ai/docs/get-started/install/pip-install#advanced-pip-installation). #### Developer installs: macOS, Linux, WSL: ```bash git clone https://github.com/unslothai/unsloth @@ -143,69 +204,13 @@ You can uninstall Unsloth Studio by deleting its install folder usually located For more info, [see our docs](https://unsloth.ai/docs/new/studio/install#uninstall). -##### Deleting model files +#### Deleting model files You can delete old model files either from the bin icon in model search or by removing the relevant cached model folder from the default Hugging Face cache directory. By default, HF uses: * ​ **MacOS, Linux, WSL:** `~/.cache/huggingface/hub/` * ​ **Windows:** `%USERPROFILE%\.cache\huggingface\hub\` -### Unsloth Core (code-based) -#### Linux, WSL: -```bash -curl -LsSf https://astral.sh/uv/install.sh | sh -uv venv unsloth_env --python 3.13 -source unsloth_env/bin/activate -uv pip install unsloth --torch-backend=auto -``` -#### Windows: -```powershell -winget install -e --id Python.Python.3.13 -winget install --id=astral-sh.uv -e -uv venv unsloth_env --python 3.13 -.\unsloth_env\Scripts\activate -uv pip install unsloth --torch-backend=auto -``` -For Windows, `pip install unsloth` works only if you have PyTorch installed. Read our [Windows Guide](https://unsloth.ai/docs/get-started/install/windows-installation). -You can use the same Docker image as Unsloth Studio. - -#### AMD, Intel: -For RTX 50x, B200, 6000 GPUs: `uv pip install unsloth --torch-backend=auto`. Read our guides for: [Blackwell](https://unsloth.ai/docs/blog/fine-tuning-llms-with-blackwell-rtx-50-series-and-unsloth) and [DGX Spark](https://unsloth.ai/docs/blog/fine-tuning-llms-with-nvidia-dgx-spark-and-unsloth).
-To install Unsloth on **AMD** and **Intel** GPUs, follow our [AMD Guide](https://unsloth.ai/docs/get-started/install/amd) and [Intel Guide](https://unsloth.ai/docs/get-started/install/intel). - -## ✨ Free Notebooks - -Train for free with our notebooks. Read our [guide](https://unsloth.ai/docs/get-started/fine-tuning-llms-guide). Add dataset, run, then deploy your trained model. - -| Model | Free Notebooks | Performance | Memory use | -|-----------|---------|--------|----------| -| **Qwen3.5 (4B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision.ipynb) | 1.5x faster | 60% less | -| **gpt-oss (20B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-Fine-tuning.ipynb) | 2x faster | 70% less | -| **Qwen3.5 GSPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_5_(4B)_Vision_GRPO.ipynb) | 2x faster | 70% less | -| **gpt-oss (20B): GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-GRPO.ipynb) | 2x faster | 80% less | -| **Qwen3: Advanced GRPO** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen3_(4B)-GRPO.ipynb) | 2x faster | 70% less | -| **Gemma 3 (4B) Vision** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Gemma3_(4B)-Vision.ipynb) | 1.7x faster | 60% less | -| **embeddinggemma (300M)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/EmbeddingGemma_(300M).ipynb) | 2x faster | 20% less | -| **Mistral Ministral 3 (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Ministral_3_VL_(3B)_Vision.ipynb) | 1.5x faster | 60% less | -| **Llama 3.1 (8B) Alpaca** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.1_(8B)-Alpaca.ipynb) | 2x faster | 70% less | -| **Llama 3.2 Conversational** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Llama3.2_(1B_and_3B)-Conversational.ipynb) | 2x faster | 70% less | -| **Orpheus-TTS (3B)** | [▶️ Start for free](https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Orpheus_(3B)-TTS.ipynb) | 1.5x faster | 50% less | - -- See all our notebooks for: [Kaggle](https://github.com/unslothai/notebooks?tab=readme-ov-file#-kaggle-notebooks), [GRPO](https://unsloth.ai/docs/get-started/unsloth-notebooks#grpo-reasoning-rl-notebooks), [TTS](https://unsloth.ai/docs/get-started/unsloth-notebooks#text-to-speech-tts-notebooks), [embedding](https://unsloth.ai/docs/new/embedding-finetuning) & [Vision](https://unsloth.ai/docs/get-started/unsloth-notebooks#vision-multimodal-notebooks) -- See [all our models](https://unsloth.ai/docs/get-started/unsloth-model-catalog) and [all our notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks) -- See detailed documentation for Unsloth [here](https://unsloth.ai/docs) - -## 🦥 Unsloth News -- **Introducing Unsloth Studio**: our new web UI for running and training LLMs. [Blog](https://unsloth.ai/docs/new/studio) -- **Qwen3.5** - 0.8B, 2B, 4B, 9B, 27B, 35-A3B, 112B-A10B are now supported. [Guide + notebooks](https://unsloth.ai/docs/models/qwen3.5/fine-tune) -- Train **MoE LLMs 12x faster** with 35% less VRAM - DeepSeek, GLM, Qwen and gpt-oss. [Blog](https://unsloth.ai/docs/new/faster-moe) -- **Embedding models**: Unsloth now supports ~1.8-3.3x faster embedding fine-tuning. [Blog](https://unsloth.ai/docs/new/embedding-finetuning) • [Notebooks](https://unsloth.ai/docs/get-started/unsloth-notebooks#embedding-models) -- New **7x longer context RL** vs. all other setups, via our new batching algorithms. [Blog](https://unsloth.ai/docs/new/grpo-long-context) -- New RoPE & MLP **Triton Kernels** & **Padding Free + Packing**: 3x faster training & 30% less VRAM. [Blog](https://unsloth.ai/docs/new/3x-faster-training-packing) -- **500K Context**: Training a 20B model with >500K context is now possible on an 80GB GPU. [Blog](https://unsloth.ai/docs/blog/500k-context-length-fine-tuning) -- **FP8 & Vision RL**: You can now do FP8 & VLM GRPO on consumer GPUs. [FP8 Blog](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/fp8-reinforcement-learning) • [Vision RL](https://unsloth.ai/docs/get-started/reinforcement-learning-rl-guide/vision-reinforcement-learning-vlm-rl) -- **gpt-oss** by OpenAI: Read our [RL blog](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune/gpt-oss-reinforcement-learning), [Flex Attention](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune/long-context-gpt-oss-training) blog and [Guide](https://unsloth.ai/docs/models/gpt-oss-how-to-run-and-fine-tune). - ## 💚 Community and Links | Type | Links | | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | From 6b5da2ea0f96af59321993fff45ffce1fe842731 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 03:06:59 -0700 Subject: [PATCH 51/94] Fix missing num_items_in_batch in unsloth_prediction_step (#4616) * Fix missing num_items_in_batch in unsloth_prediction_step unsloth_prediction_step calls compute_loss without num_items_in_batch during evaluation. This causes _unsloth_pre_compute_loss to see num_items_in_batch=None, which triggers a spurious warning for every model when gradient_accumulation_steps > 1: "Unsloth: Not an error, but {model} does not accept num_items_in_batch. Using gradient accumulation will be very slightly less accurate." The standard transformers prediction_step computes num_items_in_batch via _get_num_items_in_batch before passing it to compute_loss. This patch does the same in unsloth_prediction_step. Tested on Llama-3.2-1B-Instruct and Olmo-3-7B-Instruct with gradient_accumulation_steps=3 and eval_steps=3. Warning is gone and eval loss is computed correctly for both. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Guard _get_num_items_in_batch for older transformers versions _get_num_items_in_batch was added in transformers 4.46. Wrap the call in try/except so older versions fall back to num_items_in_batch=None, which preserves the original behavior of not passing it. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- unsloth/models/rl.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/unsloth/models/rl.py b/unsloth/models/rl.py index 581244e4d3..5651a7da41 100755 --- a/unsloth/models/rl.py +++ b/unsloth/models/rl.py @@ -273,8 +273,17 @@ def PatchRL(FastLanguageModel): with torch.no_grad(): if has_labels or loss_without_labels: with self.compute_loss_context_manager(): + try: + num_items_in_batch = self._get_num_items_in_batch( + [inputs], self.args.device + ) + except (AttributeError, TypeError): + num_items_in_batch = None loss, outputs = self.compute_loss( - model, inputs, return_outputs = True + model, + inputs, + return_outputs = True, + num_items_in_batch = num_items_in_batch, ) loss = loss.mean().detach() From 3a5e3bbd6d455464e6626f75bba0718509144d66 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 03:12:26 -0700 Subject: [PATCH 52/94] Make Studio shortcuts launch in a visible terminal (#4638) * Make Studio shortcuts launch in a visible terminal Studio shortcuts (Desktop/Start Menu) previously launched the server as a hidden background process. Closing the browser tab did not stop the server, leaving users with no obvious way to shut it down. This change makes shortcuts open a visible terminal window so users can see server output and close the terminal to stop Studio. Launcher changes (install.sh): - Add TTY detection in the launcher's main section. When a TTY is present (foreground mode), the launcher spawns a background browser-opener and then exec's the studio process directly. This means closing the terminal sends SIGHUP to studio, stopping it cleanly. When no TTY is present (background mode, e.g. macOS .app or headless), the existing _spawn_terminal behavior is preserved. - Add _open_browser_when_ready helper that polls health on the specific launch port and opens the browser once ready. - Add WSL fallback in _open_browser: uses powershell.exe Start-Process or cmd.exe /c start instead of unreliable xdg-open under WSL. Linux .desktop shortcut: - Change Terminal=false to Terminal=true so the desktop environment opens the user's default terminal emulator for the launcher. WSL support: - Remove the early-return that skipped WSL entirely. WSL now gets the launcher script and studio.conf written. - Add WSL shortcut creation: generates Windows Desktop and Start Menu .lnk files via a temp PowerShell script. Targets wt.exe (Windows Terminal) with automatic fallback to wsl.exe. Uses WSL_DISTRO_NAME for multi-distro setups. Windows launcher (install.ps1): - Add Find-FreeLaunchPort function that mirrors the Unix _find_launch_port logic, scanning Get-NetTCPConnection for busy ports and returning the first free port in the configured range. - Replace the hardcoded $basePort with the dynamic port result, with a MessageBox error dialog if no free port is found. * Fix review findings: lock race, WSL quoting, Windows port fallback Foreground lock race (10/10 reviewers): The foreground mode released the single-instance lock before exec, allowing a second launcher to acquire the lock and race for the same port during startup. Move lock release into the background subshell so it only happens after the health check passes. WSL shortcut quoting (10/10 reviewers): WSL_DISTRO_NAME values with spaces (e.g. "Ubuntu Preview", "Fedora Remix for WSL") were not quoted, causing the distro name to be split across multiple arguments. Add double-quoting around the distro name and launcher path in the generated shortcut arguments. Windows port fallback (3/10 reviewers): Find-FreeLaunchPort silently assumed no ports were listening when Get-NetTCPConnection was unavailable, which could return 8888 even when busy. Add a Test-PortBusy fallback that probes ports with TcpListener when Get-NetTCPConnection fails. Also scope the Get-NetTCPConnection query to only the port range we care about. * Skip powershell.exe shortcut creation if wslpath fails If wslpath -w fails (returns empty), do not attempt to pass a Linux-style path to powershell.exe -- it would always fail. Only run powershell.exe when we have a valid Windows path for the temp PS1 script. * Remove dead code and fix background health poll target - Remove unused _open_browser_when_ready function - Background mode now polls only the specific _launch_port instead of scanning all ports via _find_healthy_port, matching foreground behavior - Add launcher test harness (22 unit + 19 integration tests) * Fix port probe scope, lock ownership, and T4 test coverage - Test-PortBusy: bind on Any instead of Loopback to match Studio's 0.0.0.0 bind scope (prevents false-free in fallback path) - _release_lock: verify PID ownership before removing lock dir (prevents a timed-out subshell from deleting another launcher's lock) - T4 test: fail first curl call so the test actually exercises the lock-contention wait path instead of short-circuiting via fast path * Temporarily remove launcher test scripts Tests will be re-added in a follow-up PR to keep this diff focused on the launcher changes. --- install.ps1 | 49 +++++++++++++++++- install.sh | 142 +++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 166 insertions(+), 25 deletions(-) diff --git a/install.ps1 b/install.ps1 index 83dcf866e5..dede04d2c5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -166,6 +166,44 @@ function Find-HealthyStudioPort { return `$null } +function Test-PortBusy { + param([Parameter(Mandatory = `$true)][int]`$Port) + `$listener = `$null + try { + `$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, `$Port) + `$listener.Start() + return `$false + } catch { + return `$true + } finally { + if (`$listener) { try { `$listener.Stop() } catch {} } + } +} + +function Find-FreeLaunchPort { + `$maxPort = `$basePort + `$maxPortOffset + try { + `$listening = Get-NetTCPConnection -State Listen -ErrorAction Stop | + Where-Object { `$_.LocalPort -ge `$basePort -and `$_.LocalPort -le `$maxPort } | + Select-Object -ExpandProperty LocalPort + for (`$offset = 0; `$offset -le `$maxPortOffset; `$offset++) { + `$candidate = `$basePort + `$offset + if (`$candidate -notin `$listening) { + return `$candidate + } + } + } catch { + # Get-NetTCPConnection unavailable or restricted; probe ports directly + for (`$offset = 0; `$offset -le `$maxPortOffset; `$offset++) { + `$candidate = `$basePort + `$offset + if (-not (Test-PortBusy -Port `$candidate)) { + return `$candidate + } + } + } + return `$null +} + # If Studio is already healthy on any expected port, just open it and exit. `$existingPort = Find-HealthyStudioPort if (`$existingPort) { @@ -194,7 +232,16 @@ try { `$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' `$studioExe = '$SingleQuotedExePath' - `$studioCommand = '& "' + `$studioExe + '" studio -H 0.0.0.0 -p ' + `$basePort + `$launchPort = Find-FreeLaunchPort + if (-not `$launchPort) { + `$msg = "No free port found in range `$basePort-`$(`$basePort + `$maxPortOffset)" + try { + Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop + [System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null + } catch {} + exit 1 + } + `$studioCommand = '& "' + `$studioExe + '" studio -H 0.0.0.0 -p ' + `$launchPort `$launchArgs = @( '-NoExit', '-NoProfile', diff --git a/install.sh b/install.sh index 4afa514527..ca2ce84fb1 100755 --- a/install.sh +++ b/install.sh @@ -132,17 +132,12 @@ _smart_apt_install() { # ── Helper: create desktop shortcuts and launcher script ── # Usage: create_studio_shortcuts # Creates ~/.local/share/unsloth/launch-studio.sh (shared launcher), -# plus platform-specific shortcuts (Linux .desktop / macOS .app bundle). -# Skipped on WSL (no native desktop). +# plus platform-specific shortcuts (Linux .desktop / macOS .app bundle / +# WSL Windows Desktop+Start Menu .lnk). create_studio_shortcuts() { _css_exe="$1" _css_os="$2" - # Skip on WSL -- no native desktop environment - if [ "$_css_os" = "wsl" ]; then - return 0 - fi - # Validate exe if [ ! -x "$_css_exe" ]; then echo "[WARN] Cannot create shortcuts: unsloth not found at $_css_exe" @@ -271,6 +266,17 @@ _open_browser() { _url="$1" if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then open "$_url" + elif grep -qi microsoft /proc/version 2>/dev/null; then + # WSL: xdg-open is unreliable; use Windows browser via PowerShell or cmd + if command -v powershell.exe >/dev/null 2>&1; then + powershell.exe -NoProfile -Command "Start-Process '$_url'" >/dev/null 2>&1 & + elif command -v cmd.exe >/dev/null 2>&1; then + cmd.exe /c start "" "$_url" >/dev/null 2>&1 & + elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$_url" >/dev/null 2>&1 & + else + echo "Open in your browser: $_url" >&2 + fi elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$_url" >/dev/null 2>&1 & else @@ -334,6 +340,8 @@ _acquire_lock() { } _release_lock() { + [ -d "$LOCK_DIR" ] || return 0 + [ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$$" ] || return 0 rm -rf "$LOCK_DIR" } @@ -359,24 +367,48 @@ _launch_port=$(_find_launch_port) || { exit 1 } -# Launch studio in a terminal -_launch_cmd=$(printf '%q ' "$UNSLOTH_EXE" studio -H 0.0.0.0 -p "$_launch_port") -_launch_cmd=${_launch_cmd% } -_spawn_terminal "$_launch_cmd" +if [ -t 1 ]; then + # ── Foreground mode (TTY available) ── + # Background subshell: wait for studio to become healthy, release the + # single-instance lock, then open the browser. The lock stays held until + # health is confirmed so a second launcher cannot race during startup. + ( + _obwr_deadline=$(($(date +%s) + TIMEOUT_SEC)) + while [ "$(date +%s)" -lt "$_obwr_deadline" ]; do + if _check_health "$_launch_port"; then + _release_lock + _open_browser "http://localhost:$_launch_port" + exit 0 + fi + sleep "$POLL_INTERVAL_SEC" + done + # Timed out -- release the lock anyway so future launches are not blocked + _release_lock + ) & + # Clear traps so exec does not trigger _release_lock (the subshell owns it) + trap - EXIT INT TERM + exec "$UNSLOTH_EXE" studio -H 0.0.0.0 -p "$_launch_port" +else + # ── Background mode (no TTY) ── + # Used by macOS .app and headless invocations. + _launch_cmd=$(printf '%q ' "$UNSLOTH_EXE" studio -H 0.0.0.0 -p "$_launch_port") + _launch_cmd=${_launch_cmd% } + _spawn_terminal "$_launch_cmd" -# Poll for health -_deadline=$(($(date +%s) + TIMEOUT_SEC)) -while [ "$(date +%s)" -lt "$_deadline" ]; do - _port=$(_find_healthy_port) && { - _open_browser "http://localhost:$_port" - exit 0 - } - sleep "$POLL_INTERVAL_SEC" -done + # Poll for health on the specific port we launched on + _deadline=$(($(date +%s) + TIMEOUT_SEC)) + while [ "$(date +%s)" -lt "$_deadline" ]; do + if _check_health "$_launch_port"; then + _open_browser "http://localhost:$_launch_port" + exit 0 + fi + sleep "$POLL_INTERVAL_SEC" + done -echo "Unsloth Studio did not become healthy within ${TIMEOUT_SEC}s." >&2 -echo "Check logs at: $LOG_FILE" >&2 -exit 1 + echo "Unsloth Studio did not become healthy within ${TIMEOUT_SEC}s." >&2 + echo "Check logs at: $LOG_FILE" >&2 + exit 1 +fi LAUNCHER_EOF chmod +x "$_css_launcher" @@ -461,7 +493,7 @@ Name=Unsloth Studio Comment=Launch Unsloth Studio Exec="$_css_exec_escaped" Icon=$_css_icon_escaped -Terminal=false +Terminal=true StartupNotify=true Categories=Development;Science; DESKTOP_EOF @@ -557,6 +589,68 @@ STUB_EOF ln -sf "$_css_app" "$HOME/Desktop/Unsloth Studio" 2>/dev/null || true fi _css_created=1 + + elif [ "$_css_os" = "wsl" ]; then + # ── WSL: create Windows Desktop and Start Menu shortcuts ── + # Detect current WSL distro for targeted shortcut + _css_distro="${WSL_DISTRO_NAME:-}" + + # Build the wsl.exe arguments. + # Double-quote distro name and launcher path for Windows command line + # parsing so values with spaces (e.g. "Ubuntu Preview") are kept as + # single arguments. + _css_wsl_args="" + if [ -n "$_css_distro" ]; then + _css_wsl_args="-d \"$_css_distro\" " + fi + _css_wsl_args="${_css_wsl_args}-- bash -l -c \"exec \\\"$_css_launcher\\\"\"" + + # Detect whether Windows Terminal (wt.exe) is available (better UX) + _css_use_wt=false + if command -v wt.exe >/dev/null 2>&1; then + _css_use_wt=true + fi + + if [ "$_css_use_wt" = true ]; then + _css_sc_target='wt.exe' + _css_sc_args="wsl.exe $_css_wsl_args" + else + _css_sc_target='wsl.exe' + _css_sc_args="$_css_wsl_args" + fi + + # Escape single quotes for PowerShell single-quoted string embedding + _css_sc_args_ps=$(printf '%s' "$_css_sc_args" | sed "s/'/''/g") + + # Create shortcuts via a temp PowerShell script to avoid escaping issues + _css_ps1_tmp=$(mktemp /tmp/unsloth-shortcut-XXXXXX.ps1 2>/dev/null) || true + if [ -n "$_css_ps1_tmp" ]; then + cat > "$_css_ps1_tmp" << WSLPS1_EOF +\$WshShell = New-Object -ComObject WScript.Shell +\$targetExe = (Get-Command '$_css_sc_target' -ErrorAction SilentlyContinue).Source +if (-not \$targetExe) { exit 1 } +\$locations = @( + [Environment]::GetFolderPath('Desktop'), + (Join-Path \$env:APPDATA 'Microsoft\Windows\Start Menu\Programs') +) +foreach (\$dir in \$locations) { + if (-not \$dir -or -not (Test-Path \$dir)) { continue } + \$linkPath = Join-Path \$dir 'Unsloth Studio.lnk' + \$shortcut = \$WshShell.CreateShortcut(\$linkPath) + \$shortcut.TargetPath = \$targetExe + \$shortcut.Arguments = '$_css_sc_args_ps' + \$shortcut.Description = 'Launch Unsloth Studio' + \$shortcut.Save() +} +WSLPS1_EOF + + # Convert WSL path to Windows path for powershell.exe + _css_ps1_win=$(wslpath -w "$_css_ps1_tmp" 2>/dev/null) + if [ -n "$_css_ps1_win" ]; then + powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$_css_ps1_win" >/dev/null 2>&1 && _css_created=1 + fi + rm -f "$_css_ps1_tmp" + fi fi if [ "$_css_created" -eq 1 ]; then From 0233fe7f9cbac2a5a639233a87145bb2e3a8baa4 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Fri, 27 Mar 2026 10:12:48 +0000 Subject: [PATCH 53/94] studio: setup log styling (#4494) * refactor(studio): unify setup terminal output style and add verbose setup mode * studio(windows): align setup.ps1 banner/steps with setup.sh (ANSI, verbose) * studio(setup): revert nvcc path reordering to match main * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio(setup): restore fail-fast llama.cpp setup flow * studio(banner): use IPv6 loopback URL when binding :: or ::1 * Fix IPv6 URL bracketing, try_quiet stderr, _step label clamp - Bracket IPv6 display_host in external_url to produce clickable URLs - Redirect try_quiet failure log to stderr instead of stdout - Clamp _step label to column width to prevent negative padding * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add sandbox integration tests for PR #4494 UX fixes Simulation harness (tests/simulate_pr4494.py) creates an isolated uv venv, copies the real source files into it, and runs subprocess tests for all three fixes with visual before/after demos and edge cases. Standalone bash test (tests/test_try_quiet.sh) validates try_quiet stderr redirect across 8 scenarios including broken-version contrast. 39 integration tests total (14 IPv6 + 15 try_quiet + 10 _step), all existing 75 unit tests still pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Truncate step() labels in setup.sh to match PS1 and Python The %-15s printf format pads short labels but does not truncate long ones. Change to %-15.15s so labels wider than 15 chars are clipped, matching the PowerShell .Substring(0,15) and Python label[:15] logic. * Remove sandbox integration tests from PR These test files are not part of the styling fix and should not ship with this PR. * Show error output on failure instead of suppressing it - install_python_stack.py: restore _red for patch_package_file warnings (was downgraded to _dim) - setup.ps1: capture winget output and show on failure for CUDA, Node, Python, and OpenSSL installs (was piped to Out-Null) - setup.ps1: always show git pull failure warning, not just in verbose mode * Show winget error output for Git and CMake installs on failure Same capture-and-print-on-failure pattern already used for Node, Python, CUDA, and OpenSSL winget installs. * fix: preserve stderr for _run_quiet error messages in setup.sh The step() helper writes to stdout, but _run_quiet's error header was originally sent to stderr (>&2). Without the redirect, callers that separate stdout/stderr would miss the failure headline while still seeing the log body on stderr. Add >&2 to both step calls inside _run_quiet to match main's behavior. * feat: add --verbose flag to setup and update commands Wire UNSLOTH_VERBOSE=1 through _run_setup_script() so that 'unsloth studio update --verbose' (and the deprecated 'setup') passes the flag to setup.sh / setup.ps1 / install_python_stack.py. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/backend/run.py | 19 +- studio/backend/startup_banner.py | 115 ++++++++++++ studio/install_python_stack.py | 80 ++++++--- studio/setup.ps1 | 232 +++++++++++++++++++----- studio/setup.sh | 300 ++++++++++++------------------- unsloth_cli/commands/studio.py | 26 ++- 6 files changed, 489 insertions(+), 283 deletions(-) create mode 100644 studio/backend/startup_banner.py diff --git a/studio/backend/run.py b/studio/backend/run.py index b892037565..87fcdc01da 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -24,6 +24,7 @@ if str(backend_dir) not in sys.path: import _platform_compat # noqa: F401 from loggers import get_logger +from startup_banner import print_studio_access_banner logger = get_logger(__name__) @@ -338,19 +339,11 @@ def run_server( if not silent: display_host = _resolve_external_ip() if host == "0.0.0.0" else host - - print("") - print("=" * 50) - print(f"🦥 Open your web browser, and enter http://localhost:{port}") - print("=" * 50) - print("") - print("=" * 50) - print(f"🦥 Unsloth Studio is running on port {port}") - print(f" Local Access: http://localhost:{port}") - print(f" Worldwide Web Address: http://{display_host}:{port}") - print(f" API: http://{display_host}:{port}/api") - print(f" Health: http://{display_host}:{port}/api/health") - print("=" * 50) + print_studio_access_banner( + port = port, + bind_host = host, + display_host = display_host, + ) return app diff --git a/studio/backend/startup_banner.py b/studio/backend/startup_banner.py new file mode 100644 index 0000000000..54acac0540 --- /dev/null +++ b/studio/backend/startup_banner.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Terminal banner for Studio startup. + +Stdlib only — safe to import without the rest of the backend (no structlog/uvicorn). +""" + +from __future__ import annotations + +import os +import sys + + +def stdout_supports_color() -> bool: + """True if we should emit ANSI colors.""" + if os.environ.get("NO_COLOR", "").strip(): + return False + if os.environ.get("FORCE_COLOR", "").strip(): + return True + try: + return sys.stdout.isatty() + except Exception: + return False + + +def print_port_in_use_notice(original_port: int, new_port: int) -> None: + """Message when the requested port is taken and another is chosen.""" + msg = f"Port {original_port} is in use, using port {new_port} instead." + if stdout_supports_color(): + print(f"\033[38;5;245m{msg}\033[0m") + else: + print(msg) + + +def print_studio_access_banner( + *, + port: int, + bind_host: str, + display_host: str, +) -> None: + """Pretty-print URLs after the server is listening (beginner-friendly).""" + use_color = stdout_supports_color() + dim = "\033[38;5;245m" + title = "\033[38;5;150m" + local_url_style = "\033[38;5;108;1m" + secondary = "\033[38;5;109m" + reset = "\033[0m" + + def style(text: str, code: str) -> str: + return f"{code}{text}{reset}" if use_color else text + + ipv6_bind = bind_host in ("::", "::1") + if ipv6_bind: + local_url = f"http://[::1]:{port}" + alt_local = f"http://localhost:{port}" + else: + local_url = f"http://127.0.0.1:{port}" + alt_local = f"http://localhost:{port}" + if ":" in display_host: + external_url = f"http://[{display_host}]:{port}" + else: + external_url = f"http://{display_host}:{port}" + listen_all = bind_host in ("0.0.0.0", "::") + loopback_bind = bind_host in ("127.0.0.1", "localhost", "::1") + api_base = local_url if listen_all or loopback_bind else external_url + + lines: list[str] = [ + "", + style("🦥 Unsloth Studio is running", title), + style("─" * 52, dim), + style(" On this machine — open this in your browser:", dim), + style(f" {local_url}", local_url_style), + style(f" (same as {alt_local})", dim), + ] + + if listen_all and display_host not in ( + "127.0.0.1", + "localhost", + "::1", + "0.0.0.0", + "::", + ): + lines.extend( + [ + "", + style(" From another device on your network / to share:", dim), + style(f" {external_url}", secondary), + ] + ) + elif not listen_all and bind_host not in ("127.0.0.1", "localhost", "::1"): + lines.extend( + [ + "", + style(" Bound address:", dim), + style(f" {external_url}", secondary), + ] + ) + + lines.extend( + [ + "", + style(" API & health:", dim), + style(f" {api_base}/api", secondary), + style(f" {api_base}/api/health", secondary), + style("─" * 52, dim), + style( + " Tip: if you are on the same computer, use the Local link above.", + dim, + ), + "", + ] + ) + + print("\n".join(lines)) diff --git a/studio/install_python_stack.py b/studio/install_python_stack.py index bcd17c4d88..f6fd38d5d8 100644 --- a/studio/install_python_stack.py +++ b/studio/install_python_stack.py @@ -45,6 +45,7 @@ NO_TORCH = _infer_no_torch() # -- Verbosity control ---------------------------------------------------------- # By default the installer shows a minimal progress bar (one line, in-place). # Set UNSLOTH_VERBOSE=1 in the environment to restore full per-step output: +# CLI: unsloth studio setup --verbose # Linux/Mac: UNSLOTH_VERBOSE=1 ./studio/setup.sh # Windows: $env:UNSLOTH_VERBOSE="1" ; .\studio\setup.ps1 VERBOSE: bool = os.environ.get("UNSLOTH_VERBOSE", "0") == "1" @@ -96,15 +97,18 @@ def _safe_print(*args: object, **kwargs: object) -> None: ) -# -- Color support ------------------------------------------------------ +# ── Color support ────────────────────────────────────────────────────── +# Same logic as startup_banner: NO_COLOR disables, FORCE_COLOR or TTY enables. -def _enable_colors() -> bool: - """Try to enable ANSI color support. Returns True if available.""" - if not hasattr(sys.stdout, "fileno"): +def _stdout_supports_color() -> bool: + """True if we should emit ANSI colors (matches startup_banner).""" + if os.environ.get("NO_COLOR", "").strip(): return False + if os.environ.get("FORCE_COLOR", "").strip(): + return True try: - if not os.isatty(sys.stdout.fileno()): + if not sys.stdout.isatty(): return False except Exception: return False @@ -113,24 +117,26 @@ def _enable_colors() -> bool: import ctypes kernel32 = ctypes.windll.kernel32 - # Enable ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x0004) on stdout - handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE + handle = kernel32.GetStdHandle(-11) mode = ctypes.c_ulong() kernel32.GetConsoleMode(handle, ctypes.byref(mode)) kernel32.SetConsoleMode(handle, mode.value | 0x0004) - return True except Exception: return False - return True # Unix terminals support ANSI by default + return True -# Colors disabled -- Colab and most CI runners render ANSI fine, but plain output -# is cleaner in the notebook cell. Re-enable by setting _HAS_COLOR = _enable_colors() -_HAS_COLOR = False +_HAS_COLOR = _stdout_supports_color() + + +# Column layout — matches setup.sh step() helper: +# 2-space indent, 15-char label (dim), then value. +_LABEL = "deps" +_COL = 15 def _green(msg: str) -> str: - return f"\033[92m{msg}\033[0m" if _HAS_COLOR else msg + return f"\033[38;5;108m{msg}\033[0m" if _HAS_COLOR else msg def _cyan(msg: str) -> str: @@ -141,21 +147,39 @@ def _red(msg: str) -> str: return f"\033[91m{msg}\033[0m" if _HAS_COLOR else msg -def _progress(label: str) -> None: - """Print an in-place progress bar for the current install step. +def _dim(msg: str) -> str: + return f"\033[38;5;245m{msg}\033[0m" if _HAS_COLOR else msg - Uses only stdlib (sys.stdout) -- no extra packages required. - In VERBOSE mode this is a no-op; per-step labels are printed by run() instead. - """ + +def _title(msg: str) -> str: + return f"\033[38;5;150m{msg}\033[0m" if _HAS_COLOR else msg + + +_RULE = "\u2500" * 52 + + +def _step(label: str, value: str, color_fn = None) -> None: + """Print a single step line in the column format.""" + if color_fn is None: + color_fn = _green + padded = label[:_COL] + print(f" {_dim(padded)}{' ' * (_COL - len(padded))}{color_fn(value)}") + + +def _progress(label: str) -> None: + """Print an in-place progress bar aligned to the step column layout.""" global _STEP _STEP += 1 if VERBOSE: - return # verbose mode: run() already printed the label + return width = 20 filled = int(width * _STEP / _TOTAL) bar = "=" * filled + "-" * (width - filled) - end = "\n" if _STEP >= _TOTAL else "" # newline only on the final step - sys.stdout.write(f"\r[{bar}] {_STEP:2}/{_TOTAL} {label:<40}{end}") + pad = " " * (_COL - len(_LABEL)) + end = "\n" if _STEP >= _TOTAL else "" + sys.stdout.write( + f"\r {_dim(_LABEL)}{pad}[{bar}] {_STEP:2}/{_TOTAL} {label:<20}{end}" + ) sys.stdout.flush() @@ -164,14 +188,14 @@ def run( ) -> subprocess.CompletedProcess[bytes]: """Run a command; on failure print output and exit.""" if VERBOSE: - print(f" {label}...") + _step(_LABEL, f"{label}...", _dim) result = subprocess.run( cmd, stdout = subprocess.PIPE if quiet else None, stderr = subprocess.STDOUT if quiet else None, ) if result.returncode != 0: - _safe_print(_red(f"❌ {label} failed (exit code {result.returncode}):")) + _step("error", f"{label} failed (exit code {result.returncode})", _red) if result.stdout: print(result.stdout.decode(errors = "replace")) sys.exit(result.returncode) @@ -353,9 +377,7 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None: text = True, ) if result.returncode != 0: - _safe_print( - _red(f" ⚠️ Could not find package {package_name}, skipping patch") - ) + _step(_LABEL, f"package {package_name} not found, skipping patch", _red) return location = None @@ -365,11 +387,11 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None: break if not location: - _safe_print(_red(f" ⚠️ Could not determine location of {package_name}")) + _step(_LABEL, f"could not locate {package_name}", _red) return dest = Path(location) / relative_path - print(_cyan(f" Patching {dest.name} in {package_name}...")) + _step(_LABEL, f"patching {dest.name} in {package_name}...", _dim) download_file(url, dest) @@ -633,7 +655,7 @@ def install_python_stack() -> int: stderr = subprocess.DEVNULL, ) - _safe_print(_green("✅ Python dependencies installed")) + _step(_LABEL, "installed") return 0 diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 42bae42819..8a1ae4b237 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -10,13 +10,28 @@ full setup including frontend build. Supports NVIDIA GPU (full training + inference) and CPU-only (GGUF chat mode). .NOTES - Usage: powershell -ExecutionPolicy Bypass -File setup.ps1 + Default output is minimal (step/substep), aligned with studio/setup.sh. + + FULL / LEGACY LOGGING (defensible audit trail, multi-line [OK]/[WARN]/paths): + unsloth studio setup --verbose + (sets UNSLOTH_VERBOSE=1; same as install_python_stack.py) + Or: $env:UNSLOTH_VERBOSE='1'; powershell -File .\studio\setup.ps1 + Or: .\setup.ps1 --verbose #> $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $PackageDir = Split-Path -Parent $ScriptDir +# Same as: unsloth studio setup --verbose (see unsloth_cli/commands/studio.py) +foreach ($a in $args) { + if ($a -eq '--verbose' -or $a -eq '-v') { + $env:UNSLOTH_VERBOSE = '1' + break + } +} +$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1') + # Detect if running from pip install (no frontend/ dir in studio) $FrontendDir = Join-Path $ScriptDir "frontend" $OxcValidatorDir = Join-Path $ScriptDir "backend\core\data_recipe\oxc-validator" @@ -247,17 +262,138 @@ function Find-VsBuildTools { return $null } +# ───────────────────────────────────────────── +# Output style (aligned with studio/setup.sh: step / substep) +# ───────────────────────────────────────────── +$Rule = [string]::new([char]0x2500, 52) + +function Enable-StudioVirtualTerminal { + if ($env:NO_COLOR) { return $false } + try { + Add-Type -Namespace StudioVT -Name Native -MemberDefinition @' +[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int nStdHandle); +[DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m); +[DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m); +'@ -ErrorAction Stop + $h = [StudioVT.Native]::GetStdHandle(-11) + [uint32]$mode = 0 + if (-not [StudioVT.Native]::GetConsoleMode($h, [ref]$mode)) { return $false } + $mode = $mode -bor 0x0004 + return [StudioVT.Native]::SetConsoleMode($h, $mode) + } catch { + return $false + } +} +$script:StudioVtOk = Enable-StudioVirtualTerminal + +function Get-StudioAnsi { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Title', 'Dim', 'Ok', 'Warn', 'Err', 'Reset')] + [string]$Kind + ) + $e = [char]27 + switch ($Kind) { + 'Title' { return "${e}[38;5;150m" } + 'Dim' { return "${e}[38;5;245m" } + 'Ok' { return "${e}[38;5;108m" } + 'Warn' { return "${e}[38;5;136m" } + 'Err' { return "${e}[91m" } + 'Reset' { return "${e}[0m" } + } +} + +function Write-SetupVerboseDetail { + param( + [Parameter(Mandatory = $true)][string]$Message, + [string]$Color = "Gray" + ) + if (-not $script:UnslothVerbose) { return } + if ($script:StudioVtOk -and -not $env:NO_COLOR) { + $ansi = switch ($Color) { + 'Green' { (Get-StudioAnsi Ok) } + 'Gray' { (Get-StudioAnsi Dim) } + 'DarkGray' { (Get-StudioAnsi Dim) } + 'Yellow' { (Get-StudioAnsi Warn) } + 'Cyan' { (Get-StudioAnsi Title) } + 'Red' { (Get-StudioAnsi Err) } + default { (Get-StudioAnsi Dim) } + } + Write-Host ($ansi + $Message + (Get-StudioAnsi Reset)) + } else { + $fc = switch ($Color) { + 'Green' { 'DarkGreen' } + 'Gray' { 'DarkGray' } + 'Cyan' { 'Green' } + default { $Color } + } + Write-Host $Message -ForegroundColor $fc + } +} + +function step { + param( + [Parameter(Mandatory = $true)][string]$Label, + [Parameter(Mandatory = $true)][string]$Value, + [string]$Color = "Green" + ) + if ($script:StudioVtOk -and -not $env:NO_COLOR) { + $dim = Get-StudioAnsi Dim + $rst = Get-StudioAnsi Reset + $val = switch ($Color) { + 'Green' { Get-StudioAnsi Ok } + 'Yellow' { Get-StudioAnsi Warn } + 'Red' { Get-StudioAnsi Err } + 'DarkGray' { Get-StudioAnsi Dim } + default { Get-StudioAnsi Ok } + } + $padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) } + Write-Host (" {0}{1}{2}{3}{4}{2}" -f $dim, $padded, $rst, $val, $Value) + } else { + $padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) } + Write-Host (" {0}" -f $padded) -NoNewline -ForegroundColor DarkGray + $fc = switch ($Color) { + 'Green' { 'DarkGreen' } + 'Yellow' { 'Yellow' } + 'Red' { 'Red' } + 'DarkGray' { 'DarkGray' } + default { 'DarkGreen' } + } + Write-Host $Value -ForegroundColor $fc + } +} + +function substep { + param( + [Parameter(Mandatory = $true)][string]$Message, + [string]$Color = "DarkGray" + ) + if ($script:StudioVtOk -and -not $env:NO_COLOR) { + $msgCol = switch ($Color) { + 'Yellow' { (Get-StudioAnsi Warn) } + default { (Get-StudioAnsi Dim) } + } + $pad = "".PadRight(15) + Write-Host (" {0}{1}{2}{3}" -f $msgCol, $pad, $Message, (Get-StudioAnsi Reset)) + } else { + $fc = switch ($Color) { + 'Yellow' { 'Yellow' } + default { 'DarkGray' } + } + Write-Host (" {0,-15}{1}" -f "", $Message) -ForegroundColor $fc + } +} + # ───────────────────────────────────────────── # Banner # ───────────────────────────────────────────── -if ($env:SKIP_STUDIO_BASE -eq "1") { - Write-Host "+==============================================+" -ForegroundColor Green - Write-Host "| Unsloth Studio Setup (Windows) |" -ForegroundColor Green - Write-Host "+==============================================+" -ForegroundColor Green +Write-Host "" +if ($script:StudioVtOk -and -not $env:NO_COLOR) { + Write-Host (" " + (Get-StudioAnsi Title) + [char]::ConvertFromUtf32(0x1F9A5) + " Unsloth Studio Setup" + (Get-StudioAnsi Reset)) + Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset)) } else { - Write-Host "+==============================================+" -ForegroundColor Green - Write-Host "| Unsloth Studio Update (Windows) |" -ForegroundColor Green - Write-Host "+==============================================+" -ForegroundColor Green + Write-Host (" " + [char]::ConvertFromUtf32(0x1F9A5) + " Unsloth Studio Setup") -ForegroundColor Green + Write-Host " $Rule" -ForegroundColor DarkGray } # ========================================================================== @@ -303,12 +439,12 @@ if (-not $HasNvidiaSmi) { } if (-not $HasNvidiaSmi) { Write-Host "" - Write-Host "[WARN] No NVIDIA GPU detected. Studio will run in chat-only (GGUF) mode." -ForegroundColor Yellow + step "gpu" "none (chat-only / GGUF)" "Yellow" Write-Host " Training and GPU inference require an NVIDIA GPU with drivers installed." -ForegroundColor Yellow Write-Host " https://www.nvidia.com/Download/index.aspx" -ForegroundColor Yellow Write-Host "" } else { - Write-Host "[OK] NVIDIA GPU detected" -ForegroundColor Green + step "gpu" "NVIDIA GPU detected" } # ============================================ @@ -364,9 +500,9 @@ if (-not $HasGit) { Write-Host " Install Git from https://git-scm.com/download/win and re-run." -ForegroundColor Red exit 1 } - Write-Host "[OK] Git installed: $(git --version)" -ForegroundColor Green + step "git" "$(git --version)" } else { - Write-Host "[OK] Git found: $(git --version)" -ForegroundColor Green + step "git" "$(git --version)" } # ============================================ @@ -408,14 +544,14 @@ if (-not $HasCmake) { } } if ($HasCmake) { - Write-Host "[OK] CMake installed" -ForegroundColor Green + step "cmake" "installed" } else { Write-Host "[ERROR] CMake is required but could not be installed." -ForegroundColor Red Write-Host " Install CMake from https://cmake.org/download/ and re-run." -ForegroundColor Red exit 1 } } else { - Write-Host "[OK] CMake found: $(cmake --version | Select-Object -First 1)" -ForegroundColor Green + step "cmake" "$(cmake --version | Select-Object -First 1)" } # ============================================ @@ -442,7 +578,7 @@ if (-not $vsResult) { if ($vsResult) { $CmakeGenerator = $vsResult.Generator $VsInstallPath = $vsResult.InstallPath - Write-Host "[OK] $CmakeGenerator detected via $($vsResult.Source)" -ForegroundColor Green + step "vs" "$CmakeGenerator ($($vsResult.Source))" if ($vsResult.ClExe) { Write-Host " cl.exe: $($vsResult.ClExe)" -ForegroundColor Gray } } else { Write-Host "[ERROR] Visual Studio Build Tools could not be found or installed." -ForegroundColor Red @@ -713,7 +849,7 @@ if ($VsInstallPath -and $CudaToolkitRoot) { } } -Write-Host "[OK] CUDA Toolkit: $NvccPath" -ForegroundColor Green +step "cuda" $NvccPath Write-Host " CUDA_PATH = $CudaToolkitRoot" -ForegroundColor Gray Write-Host " CudaToolkitDir = $CudaToolkitRoot\" -ForegroundColor Gray @@ -730,7 +866,7 @@ if (-not $CudaArch) { # 1f. Node.js / npm (skip if pip-installed -- only needed for frontend build) # ============================================ if ($IsPipInstall) { - Write-Host "[OK] Running from pip install - frontend already bundled, skipping Node/npm check" -ForegroundColor Green + step "frontend" "bundled (pip install)" } else { # setup.sh installs Node LTS (v22) via nvm. We enforce the same range here: # Vite 8 requires Node ^20.19.0 || >=22.12.0, npm >= 11. @@ -771,7 +907,7 @@ if ($IsPipInstall) { } } - Write-Host "[OK] Node $(node -v) | npm $(npm -v)" -ForegroundColor Green + step "node" "$(node -v) | npm $(npm -v)" # ── bun (optional, faster package installs) ── # Installed via npm — Node is already guaranteed above. Works on all platforms. @@ -825,7 +961,7 @@ if ($HasPython) { Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow exit 1 } - Write-Host "[OK] Python $(python --version)" -ForegroundColor Green + step "python" "$(python --version 2>&1)" $PythonOk = $true } @@ -860,7 +996,7 @@ $DistDir = Join-Path $FrontendDir "dist" $NeedFrontendBuild = $true if ($IsPipInstall) { $NeedFrontendBuild = $false - Write-Host "[OK] Running from pip install - frontend already bundled, skipping build" -ForegroundColor Green + step "frontend" "bundled (pip install)" } elseif (Test-Path $DistDir) { $DistTime = (Get-Item $DistDir).LastWriteTime $NewerFile = $null @@ -881,7 +1017,7 @@ if ($IsPipInstall) { } if (-not $NewerFile) { $NeedFrontendBuild = $false - Write-Host "[OK] Frontend already built and up to date -- skipping build" -ForegroundColor Green + step "frontend" "up to date" } else { Write-Host "[INFO] Frontend source changed since last build -- rebuilding..." -ForegroundColor Yellow } @@ -992,10 +1128,9 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) { $CssFiles = Get-ChildItem (Join-Path $DistDir "assets") -Filter "*.css" -ErrorAction SilentlyContinue $MaxCssSize = ($CssFiles | Measure-Object -Property Length -Maximum).Maximum if ($MaxCssSize -lt 100000) { - Write-Host "[WARN] Largest CSS file is only $([math]::Round($MaxCssSize / 1024))KB -- Tailwind may not have scanned all source files." -ForegroundColor Yellow - Write-Host " Expected >100KB. Check for .gitignore files blocking the Tailwind oxide scanner." -ForegroundColor Yellow + step "frontend" "built (warning: CSS may be truncated)" "Yellow" } else { - Write-Host "[OK] Frontend built to frontend/dist (CSS: $([math]::Round($MaxCssSize / 1024))KB)" -ForegroundColor Green + step "frontend" "built" } } @@ -1319,7 +1454,7 @@ if ($LASTEXITCODE -ne 0) { Write-Host "[WARN] Could not install tiktoken into .venv_t5/ -- Qwen tokenizers may fail" -ForegroundColor Yellow } $ErrorActionPreference = $prevEAP_t5 -Write-Host "[OK] Transformers 5.x pre-installed to .venv_t5/" -ForegroundColor Green +step "transformers" "5.x pre-installed" # ========================================================================== # PHASE 3.4: Prefer prebuilt llama.cpp bundles before source build @@ -1400,7 +1535,7 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") { $ErrorActionPreference = $prevEAPPrebuilt if ($prebuiltExit -eq 0) { - Write-Host "[OK] Prebuilt llama.cpp installed and validated" -ForegroundColor Green + step "llama.cpp" "prebuilt installed and validated" } else { if (Test-Path $LlamaCppDir) { Write-Host "[WARN] Prebuilt update failed; existing install was restored or cleaned before source build fallback" -ForegroundColor Yellow @@ -1495,10 +1630,10 @@ if (Test-Path $LlamaServerBin) { if (-not $NeedLlamaSourceBuild) { Write-Host "" - Write-Host "[OK] Using validated prebuilt llama.cpp install at $LlamaCppDir" -ForegroundColor Green + step "llama.cpp" "prebuilt (validated)" } elseif ((Test-Path $LlamaServerBin) -and -not $NeedRebuild) { Write-Host "" - Write-Host "[OK] llama-server already exists at $LlamaServerBin" -ForegroundColor Green + step "llama.cpp" "already built" } elseif (-not $HasCmakeForBuild) { Write-Host "" if (-not $HasNvidiaSmi) { @@ -1719,38 +1854,37 @@ if (-not $NeedLlamaSourceBuild) { $totalSec = [math]::Round($totalSw.Elapsed.TotalSeconds % 60, 1) # -- Summary -- - Write-Host "" if ($BuildOk -and (Test-Path $LlamaServerBin)) { - Write-Host "[OK] llama-server built at $LlamaServerBin" -ForegroundColor Green + step "llama.cpp" "built" $QuantizeBin = Join-Path $BuildDir "bin\Release\llama-quantize.exe" if (Test-Path $QuantizeBin) { - Write-Host "[OK] llama-quantize available for GGUF export" -ForegroundColor Green + step "llama-quantize" "built" } - Write-Host " Build time: ${totalMin}m ${totalSec}s" -ForegroundColor Cyan + step "build time" "${totalMin}m ${totalSec}s" "DarkGray" } else { - # Check alternate paths (some cmake generators don't use Release subdir) $altBin = Join-Path $BuildDir "bin\llama-server.exe" if ($BuildOk -and (Test-Path $altBin)) { - Write-Host "[OK] llama-server built at $altBin" -ForegroundColor Green - Write-Host " Build time: ${totalMin}m ${totalSec}s" -ForegroundColor Cyan + step "llama.cpp" "built" + step "build time" "${totalMin}m ${totalSec}s" "DarkGray" } else { - Write-Host "[FAILED] llama.cpp build failed at step: $FailedStep (${totalMin}m ${totalSec}s)" -ForegroundColor Red - Write-Host " To retry: delete $LlamaCppDir and re-run setup." -ForegroundColor Yellow + step "llama.cpp" "build failed at: $FailedStep (${totalMin}m ${totalSec}s)" "Red" + substep "To retry: delete $LlamaCppDir and re-run setup." "Yellow" exit 1 } } } -# ============================================ -# Done -# ============================================ +# ───────────────────────────────────────────── +# Footer +# ───────────────────────────────────────────── +if ($script:StudioVtOk -and -not $env:NO_COLOR) { + Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset)) + Write-Host (" " + (Get-StudioAnsi Title) + "Unsloth Studio Installed" + (Get-StudioAnsi Reset)) + Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset)) +} else { + Write-Host " $Rule" -ForegroundColor DarkGray + Write-Host " Unsloth Studio Installed" -ForegroundColor Green + Write-Host " $Rule" -ForegroundColor DarkGray +} +step "launch" "unsloth studio -H 0.0.0.0 -p 8888" Write-Host "" -$doneLine = if ($env:SKIP_STUDIO_BASE -eq "1") { "Setup Complete!" } else { "Update Complete!" } -$doneContent = " $doneLine" -Write-Host "+===============================================+" -ForegroundColor Green -Write-Host ("|" + $doneContent.PadRight(47) + "|") -ForegroundColor Green -Write-Host "| |" -ForegroundColor Green -Write-Host "| Launch with: |" -ForegroundColor Green -Write-Host "| unsloth studio -H 0.0.0.0 -p 8888 |" -ForegroundColor Green -Write-Host "| |" -ForegroundColor Green -Write-Host "+===============================================+" -ForegroundColor Green diff --git a/studio/setup.sh b/studio/setup.sh index 6f43446757..7502270276 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -6,6 +6,27 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +RULE=$(printf '\342\224\200%.0s' {1..52}) + +# ── Colors (same palette as startup_banner / install_python_stack) ── +if [ -n "${NO_COLOR:-}" ]; then + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then + C_TITLE=$'\033[38;5;150m' + C_DIM=$'\033[38;5;245m' + C_OK=$'\033[38;5;108m' + C_WARN=$'\033[38;5;136m' + C_ERR=$'\033[91m' + C_RST=$'\033[0m' +else + C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST= +fi + +# ── Output helpers ── +# Consistent column layout: 2-space indent, 15-char label (fits llama-quantize), then value. +# Usage: step
@@ -340,7 +339,7 @@ export function Navbar() { setShutdownOpen(true); }} > - + Quit Unsloth Studio
From c4e34c88c887d90b4d8c307eb3f72f06ca9cc8be Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 05:57:49 -0700 Subject: [PATCH 65/94] Fall back to parsing model name when HF API has no param count (#4656) Some models like unsloth/Qwen3-0.6B have no safetensors metadata on Hugging Face, so the training model selector showed no parameter size badge. The chat model picker already had extractParamLabel() as a fallback that parses sizes like "0.6B" from the model name. Add the same fallback to the training model selector and the onboarding model selection step. Co-authored-by: Daniel Han --- .../onboarding/components/steps/model-selection-step.tsx | 9 ++++++++- .../src/features/studio/sections/model-section.tsx | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx b/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx index f05643c092..4e7454e20f 100644 --- a/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx +++ b/studio/frontend/src/features/onboarding/components/steps/model-selection-step.tsx @@ -58,6 +58,13 @@ import { HugeiconsIcon } from "@hugeicons/react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; +/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */ +function extractParamLabel(id: string): string | null { + const name = id.split("/").pop() ?? id; + const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/); + return match ? `${match[1]}B` : null; +} + export function ModelSelectionStep() { const gpu = useGpuInfo(); const { @@ -119,7 +126,7 @@ export function ModelSelectionStep() { const fit = fitMap.get(r.id); map.set(r.id, { status: fit?.status ?? null, - detail: r.totalParams ? formatCompact(r.totalParams) : null, + detail: r.totalParams ? formatCompact(r.totalParams) : extractParamLabel(r.id), }); } return map; diff --git a/studio/frontend/src/features/studio/sections/model-section.tsx b/studio/frontend/src/features/studio/sections/model-section.tsx index fa732bf62a..0d2b3e074d 100644 --- a/studio/frontend/src/features/studio/sections/model-section.tsx +++ b/studio/frontend/src/features/studio/sections/model-section.tsx @@ -72,6 +72,13 @@ const DARK_CONTENT = const DARK_COMBOBOX_CONTENT = "bg-foreground text-background shadow-xl border-background/10 dark:[--accent:rgba(2,6,23,0.08)] dark:[--accent-foreground:rgb(2,6,23)] dark:[&_[data-slot=combobox-item]]:text-slate-900 dark:[&_.text-muted-foreground]:text-slate-500"; +/** Extract param count label from model name (e.g. "Qwen3-0.6B" -> "0.6B"). */ +function extractParamLabel(id: string): string | null { + const name = id.split("/").pop() ?? id; + const match = name.match(/(?:^|[-_])(\d+(?:\.\d+)?)[Bb](?:[-_]|$)/); + return match ? `${match[1]}B` : null; +} + export function ModelSection() { const gpu = useGpuInfo(); @@ -233,7 +240,7 @@ export function ModelSection() { { est: number; status: VramFitStatus | null; detail: string | null } >(); for (const r of hfResults) { - const detail = r.totalParams ? formatCompact(r.totalParams) : null; + const detail = r.totalParams ? formatCompact(r.totalParams) : extractParamLabel(r.id); const fit = fitMap.get(r.id); map.set(r.id, { est: fit?.est ?? 0, From 73969a1e4f57f8c296ba1e1bc54c446ad2e3add0 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Fri, 27 Mar 2026 14:53:33 +0100 Subject: [PATCH 66/94] fix: disable OCR in pymupdf4llm PDF extraction (#4659) --- studio/backend/routes/data_recipe/seed.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/studio/backend/routes/data_recipe/seed.py b/studio/backend/routes/data_recipe/seed.py index 74a3abe972..e9cf828610 100644 --- a/studio/backend/routes/data_recipe/seed.py +++ b/studio/backend/routes/data_recipe/seed.py @@ -388,7 +388,7 @@ def _extract_text_from_file(file_path: Path, ext: str) -> str: import pymupdf4llm raw = pymupdf4llm.to_markdown( - str(file_path), write_images = False, show_progress = False + str(file_path), write_images = False, show_progress = False, use_ocr = False ) elif ext == ".docx": import mammoth From 562e54fc6e78409bf0ef30c6eb30c1f59b496635 Mon Sep 17 00:00:00 2001 From: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> Date: Fri, 27 Mar 2026 17:59:27 +0400 Subject: [PATCH 67/94] Fix HF cache default and show LM Studio models in chat/inference (#4653) * fix: default HF cache to standard platform path instead of legacy Unsloth cache * feat: show LM Studio and local models in chat Fine-tuned tab * feat: show LM Studio models in Hub models tab * fix: fetch local models after auth refresh completes * Revert "fix: fetch local models after auth refresh completes" This reverts commit cfd61f0ac76a6f578f14bcd0c668bb011b0ff330. * fix: increase llama-server health check timeout to 600s for large models * feat: expandable GGUF variant picker for LM Studio local models * fix: show GGUF variant label for locally loaded LM Studio models * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: show publisher name in LM Studio model labels * fix: set model_id for loose GGUF files in LM Studio publisher dirs * fix: show publisher prefix in Fine-tuned tab LM Studio models * fix: only use model_id for lmstudio source models * fix: only show LM Studio models in Hub tab on Mac/chat-only mode * fix: respect XDG_CACHE_HOME, handle Windows paths in isLocalPath, refresh LM Studio on remount - _setup_cache_env now reads XDG_CACHE_HOME (falls back to ~/.cache) instead of hard-coding ~/.cache/huggingface. This follows the standard HF cache resolution chain and respects distro/container overrides. - isLocalPath in GgufVariantExpander uses a regex that covers Windows drive letters (C:\, D:/), UNC paths (\\server\share), relative paths (./, ../), and tilde (~/) -- not just startsWith("/"). - HubModelPicker.useEffect now calls listLocalModels() before the alreadyCached early-return gate so LM Studio models are always refreshed on remount. Also seeds useState from _lmStudioCache for instant display on re-open. * fix: add comment explaining isLocalPath regex for Windows/cross-platform paths * fix: prioritize unsloth publisher in LM Studio model list * fix: scope unsloth-first sort to LM Studio models on all platforms * fix: add missing _lmStudioCache module-level declaration * fix: prioritize unsloth publisher before timestamp sort in LM Studio group --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han --- studio/backend/core/inference/llama_cpp.py | 15 +- studio/backend/routes/models.py | 30 +++- studio/backend/utils/models/model_config.py | 72 ++++++++- studio/backend/utils/paths/storage_roots.py | 23 +-- .../assistant-ui/model-selector/pickers.tsx | 153 ++++++++++++++---- .../assistant-ui/model-selector/types.ts | 4 +- .../src/features/chat/api/chat-api.ts | 21 +++ .../frontend/src/features/chat/chat-page.tsx | 39 +++-- 8 files changed, 297 insertions(+), 60 deletions(-) diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 7909af8a23..05e038dbb7 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1292,7 +1292,18 @@ class LlamaCppBackend: self._gguf_path = gguf_path self._hf_repo = hf_repo - self._hf_variant = hf_variant + # For local GGUF files, extract variant from filename if not provided + if hf_variant: + self._hf_variant = hf_variant + elif gguf_path: + try: + from utils.models.model_config import _extract_quant_label + + self._hf_variant = _extract_quant_label(gguf_path) + except Exception: + self._hf_variant = None + else: + self._hf_variant = None self._is_vision = is_vision self._model_identifier = model_identifier @@ -1304,7 +1315,7 @@ class LlamaCppBackend: ) # Wait for llama-server to become healthy - if not self._wait_for_health(timeout = 120.0): + if not self._wait_for_health(timeout = 600.0): self._kill_process() raise RuntimeError( "llama-server failed to start. " diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index f76034c95b..348ffbf6ea 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -271,6 +271,7 @@ def _scan_lmstudio_dir(lm_dir: Path) -> List[LocalModelInfo]: found.append( LocalModelInfo( id = str(model_dir), + model_id = f"{child.name}/{model_dir.stem}", display_name = model_dir.stem, path = str(model_dir), source = "lmstudio", @@ -725,13 +726,40 @@ async def get_gguf_variants( current_subject: str = Depends(get_current_subject), ): """ - List available GGUF quantization variants for a HuggingFace repo. + List available GGUF quantization variants for a HuggingFace repo + or a local directory (e.g. LM Studio model folder). Returns all available quantization variants (Q4_K_M, Q8_0, BF16, etc.) with file sizes, whether the model supports vision, and the recommended default variant. """ try: + from utils.models.model_config import is_local_path, list_local_gguf_variants + + # Local directory path (e.g. LM Studio models) — scan filesystem + if is_local_path(repo_id): + variants, has_vision = list_local_gguf_variants(repo_id) + + filenames = [v.filename for v in variants] + best = _pick_best_gguf(filenames) + default_variant = _extract_quant_label(best) if best else None + + return GgufVariantsResponse( + repo_id = repo_id, + variants = [ + GgufVariantDetail( + filename = v.filename, + quant = v.quant, + size_bytes = v.size_bytes, + downloaded = True, # all local variants are downloaded + ) + for v in variants + ], + has_vision = has_vision, + default_variant = default_variant, + ) + + # Remote HuggingFace repo — query HF API variants, has_vision = list_gguf_variants(repo_id, hf_token = hf_token) # Determine default variant diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 13f1b5febf..5de3fd2cf9 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -973,6 +973,73 @@ def list_gguf_variants( return variants, has_vision +def list_local_gguf_variants( + directory: str, +) -> tuple[list[GgufVariantInfo], bool]: + """List GGUF quantization variants in a local directory. + + Mirrors :func:`list_gguf_variants` but reads from the filesystem + instead of the HuggingFace API. Aggregates shard sizes by quant + label so that split GGUFs appear as a single variant. + + Returns: + (variants, has_vision): list of non-mmproj GGUF variants + vision flag. + """ + p = Path(directory) + if not p.is_dir(): + return [], False + + quant_totals: dict[str, int] = {} + quant_first_file: dict[str, str] = {} + has_vision = False + + for f in sorted(p.glob("*.gguf")): + if _is_mmproj(f.name): + has_vision = True + continue + try: + size = f.stat().st_size + except OSError: + size = 0 + quant = _extract_quant_label(f.name) + quant_totals[quant] = quant_totals.get(quant, 0) + size + if quant not in quant_first_file: + quant_first_file[quant] = f.name + + variants = [ + GgufVariantInfo( + filename = quant_first_file[q], + quant = q, + size_bytes = s, + ) + for q, s in quant_totals.items() + ] + variants.sort(key = lambda v: -v.size_bytes) + return variants, has_vision + + +def _find_local_gguf_by_variant(directory: str, variant: str) -> Optional[str]: + """Find the GGUF file in *directory* matching a quantization *variant*. + + For sharded GGUFs (multiple files with the same quant label), returns + the first shard (sorted by name) which is what ``llama-server -m`` expects. + + Returns the resolved absolute path, or ``None`` if no match. + """ + p = Path(directory) + if not p.is_dir(): + return None + + matches = sorted( + f + for f in p.glob("*.gguf") + if not _is_mmproj(f.name) and _extract_quant_label(f.name) == variant + ) + if matches: + return str(matches[0].resolve()) + return None + + def detect_gguf_model_remote( repo_id: str, hf_token: Optional[str] = None, @@ -1530,7 +1597,10 @@ class ModelConfig: # Auto-detect GGUF models (check before LoRA/vision detection) if is_local: - gguf_file = detect_gguf_model(path) + if gguf_variant: + gguf_file = _find_local_gguf_by_variant(path, gguf_variant) + else: + gguf_file = detect_gguf_model(path) if gguf_file: display_name = Path(gguf_file).stem logger.info(f"Detected local GGUF model: {gguf_file}") diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 9bcf3758ad..4841c5d0a3 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -133,27 +133,28 @@ def lmstudio_model_dirs() -> list[Path]: def _setup_cache_env() -> None: """Set cache environment variables for HuggingFace, uv, and vLLM. - HuggingFace cache variables are only set when the legacy Unsloth HF - cache already exists, preserving existing model locations. New - installations leave HF at its own defaults. + Respects the standard HF cache resolution chain: explicit ``HF_HOME`` + / ``HF_HUB_CACHE`` env vars take priority, then ``XDG_CACHE_HOME``, + then the platform default (``~/.cache/huggingface``). The legacy + Unsloth cache is still *scanned* for models but is never set as the + active download target. Only sets variables that are not already set by the user, so explicit overrides (e.g. HF_HOME=/data/hf) are respected. Works on Linux, macOS, and Windows. """ root = cache_root() - hf_dir = root / "huggingface" + xdg_cache = Path( + os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache") + ).expanduser() + hf_default = xdg_cache / "huggingface" defaults: dict[str, str] = { + "HF_HOME": str(hf_default), + "HF_HUB_CACHE": str(hf_default / "hub"), + "HF_XET_CACHE": str(hf_default / "xet"), "UV_CACHE_DIR": str(root / "uv"), "VLLM_CACHE_ROOT": str(root / "vllm"), } - # Preserve legacy HF cache for existing installations - legacy_hub = hf_dir / "hub" - if legacy_hub.is_dir() and any(legacy_hub.iterdir()): - defaults["HF_HOME"] = str(hf_dir) - defaults["HF_HUB_CACHE"] = str(legacy_hub) - defaults["HF_XET_CACHE"] = str(hf_dir / "xet") - for key, value in defaults.items(): if key not in os.environ: os.environ[key] = value diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 8d4b6ae0d6..3ac3416df4 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -18,8 +18,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { deleteCachedModel, listCachedGguf, listCachedModels, listGgufVariants } from "@/features/chat/api/chat-api"; -import type { CachedGgufRepo, CachedModelRepo } from "@/features/chat/api/chat-api"; +import { deleteCachedModel, listCachedGguf, listCachedModels, listGgufVariants, listLocalModels } from "@/features/chat/api/chat-api"; +import type { CachedGgufRepo, CachedModelRepo, LocalModelInfo } from "@/features/chat/api/chat-api"; import type { GgufVariantDetail } from "@/features/chat/types/api"; import { usePlatformStore } from "@/config/env"; import { @@ -203,17 +203,20 @@ function GgufVariantExpander({ }; }, [repoId]); + // Covers Unix absolute (/), Windows drive (C:\, D:/), UNC (\\server), relative (./, ../), tilde (~/) + const isLocalPath = /^(\/|\.{1,2}[\\\/]|~[\\\/]|[A-Za-z]:[\\\/]|\\\\)/.test(repoId); + const handleVariantClick = useCallback( (quant: string, downloaded?: boolean, sizeBytes?: number) => { onSelect(repoId, { - source: "hub", + source: isLocalPath ? "local" : "hub", isLora: false, ggufVariant: quant, - isDownloaded: downloaded, + isDownloaded: isLocalPath ? true : downloaded, expectedBytes: sizeBytes, }); }, - [repoId, onSelect], + [repoId, isLocalPath, onSelect], ); // GGUF fit classification matching llama-server's _select_gpus logic: @@ -380,6 +383,17 @@ function extractParamLabel(id: string): string | undefined { // Module-level caches so re-mounting the popover shows results instantly let _cachedGgufCache: CachedGgufRepo[] = []; let _cachedModelsCache: CachedModelRepo[] = []; +let _lmStudioCache: LocalModelInfo[] = []; + +/** Sort LM Studio models with unsloth publisher first. */ +function sortLmStudio(models: LocalModelInfo[]): LocalModelInfo[] { + return [...models].sort((a, b) => { + const aUnsloth = (a.model_id ?? "").startsWith("unsloth/") ? 0 : 1; + const bUnsloth = (b.model_id ?? "").startsWith("unsloth/") ? 0 : 1; + if (aUnsloth !== bUnsloth) return aUnsloth - bUnsloth; + return (a.model_id ?? a.display_name).localeCompare(b.model_id ?? b.display_name); + }); +} // ── Hub Model Picker ────────────────────────────────────────── @@ -413,12 +427,28 @@ export function HubModelPicker({ const alreadyCached = _cachedGgufCache.length > 0 || _cachedModelsCache.length > 0; const [cachedReady, setCachedReady] = useState(alreadyCached); + // LM Studio local models -- module-level cache so re-mounting the + // popover does not flash an empty section (same pattern as GGUF/models). + const [lmStudioModels, setLmStudioModels] = useState(_lmStudioCache); + const refreshCachedLists = useCallback(() => { listCachedGguf().then((v) => { _cachedGgufCache = v; setCachedGguf(v); }).catch(() => {}); listCachedModels().then((v) => { _cachedModelsCache = v; setCachedModels(v); }).catch(() => {}); + listLocalModels().then((res) => { + const next = sortLmStudio(res.models.filter((m) => m.source === "lmstudio")); + _lmStudioCache = next; + setLmStudioModels(next); + }).catch(() => {}); }, []); useEffect(() => { + // Always refresh LM Studio models (not gated by alreadyCached) + listLocalModels().then((res) => { + const next = sortLmStudio(res.models.filter((m) => m.source === "lmstudio")); + _lmStudioCache = next; + setLmStudioModels(next); + }).catch(() => {}); + if (alreadyCached) return; let done = 0; const check = () => { if (++done >= 2) setCachedReady(true); }; @@ -686,6 +716,40 @@ export function HubModelPicker({ ) : null} + {!showHfSection && chatOnly && lmStudioModels.length > 0 ? ( + <> + LM Studio + {lmStudioModels.map((m) => { + const isGguf = isGgufRepo(m.id) || isGgufRepo(m.display_name); + return ( +
+ { + if (isGguf) { + setExpandedGguf((prev) => (prev === m.id ? null : m.id)); + } else { + onSelect(m.id, { source: "local", isLora: false, isDownloaded: true }); + } + }} + vramStatus={null} + /> + {expandedGguf === m.id && ( + + )} +
+ ); + })} + + ) : null} + {!showHfSection && cachedReady ? ( <> {"\uD83E\uDDA5"} Recommended @@ -837,6 +901,8 @@ export function LoraModelPicker({ onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; }) { const [query, setQuery] = useState(""); + const [expandedGguf, setExpandedGguf] = useState(null); + const gpu = useGpuInfo(); const normalized = useMemo( () => @@ -846,11 +912,17 @@ export function LoraModelPicker({ baseModel: model.baseModel || model.description || "Unknown base model", })) .sort((a, b) => { + const baseCmp = a.baseModel.localeCompare(b.baseModel); + if (baseCmp !== 0) return baseCmp; + // Prioritize unsloth publisher within LM Studio group + if (a.baseModel === "LM Studio" && b.baseModel === "LM Studio") { + const aUnsloth = a.name.startsWith("unsloth/") ? 0 : 1; + const bUnsloth = b.name.startsWith("unsloth/") ? 0 : 1; + if (aUnsloth !== bUnsloth) return aUnsloth - bUnsloth; + } const aTime = a.updatedAt ?? -1; const bTime = b.updatedAt ?? -1; if (aTime !== bTime) return bTime - aTime; - const baseCmp = a.baseModel.localeCompare(b.baseModel); - if (baseCmp !== 0) return baseCmp; return a.name.localeCompare(b.name); }), [loraModels], @@ -905,34 +977,53 @@ export function LoraModelPicker({ {index > 0 ?
: null} {baseModel} {adapters.map((adapter) => { + const isLocal = adapter.source === "local"; const isExported = adapter.source === "exported"; const isMerged = adapter.exportType === "merged"; const isGguf = adapter.exportType === "gguf"; - const tag = isGguf - ? "GGUF" - : isExported - ? isMerged ? "Merged" : "LoRA" - : "LoRA"; - const meta = isExported ? `${tag} · Exported` : tag; + const isLocalGgufDir = isLocal && (isGgufRepo(adapter.id) || isGgufRepo(adapter.name)); + const tag = isLocal + ? isLocalGgufDir ? "GGUF" : "Local" + : isGguf + ? "GGUF" + : isExported + ? isMerged ? "Merged" : "LoRA" + : "LoRA"; + const meta = isLocal ? (isLocalGgufDir ? "GGUF" : "Local") : isExported ? `${tag} · Exported` : tag; return ( - onSelect(adapter.id, { - source: isExported ? "exported" : "lora", - isLora: !isMerged && !isGguf, - })} - tooltipText={ - <> - {adapter.name} - - {adapter.id} - - - } - /> +
+ { + if (isLocalGgufDir) { + setExpandedGguf((prev) => (prev === adapter.id ? null : adapter.id)); + } else { + onSelect(adapter.id, { + source: isLocal ? "local" : isExported ? "exported" : "lora", + isLora: !isLocal && !isMerged && !isGguf, + }); + } + }} + tooltipText={ + <> + {adapter.name} + + {adapter.id} + + + } + /> + {expandedGguf === adapter.id && ( + + )} +
); })}
diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/components/assistant-ui/model-selector/types.ts index 9da7fd975f..f70cfc3b01 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/types.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts @@ -13,12 +13,12 @@ export interface ModelOption { export interface LoraModelOption extends ModelOption { baseModel?: string; updatedAt?: number; - source?: "training" | "exported"; + source?: "training" | "exported" | "local"; exportType?: "lora" | "merged" | "gguf"; } export interface ModelSelectorChangeMeta { - source: "hub" | "lora" | "exported"; + source: "hub" | "lora" | "exported" | "local"; isLora: boolean; ggufVariant?: string; isDownloaded?: boolean; diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index 57cbcccf66..bb603b90c4 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -125,6 +125,27 @@ export async function getDownloadProgress( return parseJsonOrThrow(response); } +export interface LocalModelInfo { + id: string; + display_name: string; + path: string; + source: "models_dir" | "hf_cache" | "lmstudio"; + model_id?: string | null; + updated_at?: number | null; +} + +interface LocalModelListResponse { + models_dir: string; + hf_cache_dir?: string | null; + lmstudio_dirs: string[]; + models: LocalModelInfo[]; +} + +export async function listLocalModels(): Promise { + const response = await authFetch("/api/models/local"); + return parseJsonOrThrow(response); +} + export async function listCachedGguf(): Promise { const response = await authFetch("/api/models/cached-gguf"); const data = await parseJsonOrThrow<{ cached: CachedGgufRepo[] }>(response); diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index c04cfbc89c..07b52ebc30 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -37,6 +37,7 @@ import { } from "react"; import { toast } from "sonner"; import { GuidedTour, useGuidedTourController } from "@/features/tour"; +import { listLocalModels } from "./api/chat-api"; import { ChatSettingsPanel } from "./chat-settings-sheet"; import { ContextUsageBar } from "./components/context-usage-bar"; import { ModelLoadInlineStatus } from "./components/model-load-status"; @@ -578,22 +579,36 @@ export function ChatPage(): ReactElement { [modelsFromStore], ); - const loraModels = useMemo( - () => - lorasFromStore.map((lora) => ({ - id: lora.id, - name: lora.name, - baseModel: lora.baseModel, - updatedAt: lora.updatedAt, - source: lora.source, - exportType: lora.exportType, - })), - [lorasFromStore], - ); + const [localModels, setLocalModels] = useState([]); + + const loraModels = useMemo(() => { + const fromLoras = lorasFromStore.map((lora) => ({ + id: lora.id, + name: lora.name, + baseModel: lora.baseModel, + updatedAt: lora.updatedAt, + source: lora.source, + exportType: lora.exportType, + })); + return [...fromLoras, ...localModels]; + }, [lorasFromStore, localModels]); useEffect(() => { if (getTrainingCompareHandoff()) return; void refresh(); + void listLocalModels().then((res) => { + setLocalModels( + res.models + .filter((m) => m.source === "lmstudio" || m.source === "models_dir") + .map((m) => ({ + id: m.id, + name: m.source === "lmstudio" && m.model_id ? m.model_id : m.display_name, + baseModel: m.source === "lmstudio" ? "LM Studio" : "Local models", + updatedAt: m.updated_at ?? undefined, + source: "local" as const, + })), + ); + }).catch(() => {}); }, [refresh]); useEffect(() => { From 844a816ed0ffc2f7bc5c01e4d527f66465429f9b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 07:14:03 -0700 Subject: [PATCH 68/94] Update pyproject.toml --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c7dd0b962..e2173d6811 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ huggingfacenotorch = [ ] huggingface = [ "unsloth[huggingfacenotorch]", - "unsloth_zoo>=2026.3.5", + "unsloth_zoo>=2026.3.6", "torchvision", "unsloth[triton]", ] @@ -577,7 +577,7 @@ colab-ampere-torch220 = [ "flash-attn>=2.6.3 ; ('linux' in sys_platform)", ] colab-new = [ - "unsloth_zoo>=2026.3.5", + "unsloth_zoo>=2026.3.6", "packaging", "tyro", "transformers>=4.51.3,!=4.52.0,!=4.52.1,!=4.52.2,!=4.52.3,!=4.53.0,!=4.54.0,!=4.55.0,!=4.55.1,!=4.57.0,!=4.57.4,!=4.57.5,!=5.0.0,!=5.1.0,<=5.3.0", From df3b18c579e4c140ec1f3d193d8306ba28142842 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 07:24:39 -0700 Subject: [PATCH 69/94] Update _utils.py --- unsloth/models/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index 70d5420f84..abdd19c615 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.3.15" +__version__ = "2026.3.16" __all__ = [ "SUPPORTS_BFLOAT16", From 9477e7c43fe8c8c0cf46d508bfa594fd767f1089 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 07:47:08 -0700 Subject: [PATCH 70/94] Bump minimum unsloth version to 2026.3.16 in install scripts (#4663) Update install.sh and install.ps1 to require unsloth>=2026.3.16, matching the latest PyPI release. --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 215c8f6040..f1f16d818d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -681,13 +681,13 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo + uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { uv pip install --python $VenvPython --no-deps -r $NoTorchReq } } else { - uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo + uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo } if ($StudioLocalInstall) { Write-Host "==> Overlaying local repo (editable)..." @@ -709,7 +709,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.14" unsloth-zoo + uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.3.16" unsloth-zoo $NoTorchReq = Find-NoTorchRuntimeFile if ($NoTorchReq) { uv pip install --python $VenvPython --no-deps -r $NoTorchReq @@ -719,7 +719,7 @@ shell.Run cmd, 0, False uv pip install --python $VenvPython -e $RepoRoot --no-deps } } elseif ($StudioLocalInstall) { - uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo + uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } else { @@ -729,7 +729,7 @@ shell.Run cmd, 0, False # Fallback: GPU detection failed to produce a URL -- let uv resolve torch Write-Host "==> Installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.14" --torch-backend=auto + uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto Write-Host "==> Overlaying local repo (editable)..." uv pip install --python $VenvPython -e $RepoRoot --no-deps } else { diff --git a/install.sh b/install.sh index 4c960a129e..b50256ea5a 100755 --- a/install.sh +++ b/install.sh @@ -968,7 +968,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.3.14" unsloth-zoo + "unsloth>=2026.3.16" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" @@ -976,7 +976,7 @@ if [ "$_MIGRATED" = true ]; then else uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.3.14" unsloth-zoo + "unsloth>=2026.3.16" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then echo "==> Overlaying local repo (editable)..." @@ -998,7 +998,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.3.14" unsloth-zoo + "unsloth>=2026.3.16" unsloth-zoo _NO_TORCH_RT="$(_find_no_torch_runtime)" if [ -n "$_NO_TORCH_RT" ]; then uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT" @@ -1009,7 +1009,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.3.14" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.3.16" unsloth-zoo echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else @@ -1020,7 +1020,7 @@ else # Fallback: GPU detection failed to produce a URL -- let uv resolve torch echo "==> Installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.14" --torch-backend=auto + uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.3.16" --torch-backend=auto echo "==> Overlaying local repo (editable)..." uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps else From 82d14b44d3f4c5c5ac44733bb0a00df197c80bdb Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 08:19:41 -0700 Subject: [PATCH 71/94] fix: preserve Windows drive-letter paths on native Windows (#4665) normalize_path() unconditionally converted Windows paths like C:\Users\... to WSL format /mnt/c/Users/..., which breaks path resolution on native Windows. This caused LM Studio GGUF models to fail detection (detect_gguf_model returned None for the invalid path), falling through to the Unsloth import path which requires a GPU. Now only performs the /mnt/ mapping when actually running under WSL. On native Windows, drive letters are preserved and backslashes are normalized to forward slashes. --- studio/backend/utils/paths/path_utils.py | 36 ++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/studio/backend/utils/paths/path_utils.py b/studio/backend/utils/paths/path_utils.py index 1d6a952399..b38db18286 100644 --- a/studio/backend/utils/paths/path_utils.py +++ b/studio/backend/utils/paths/path_utils.py @@ -6,6 +6,7 @@ Path utilities for model and dataset handling """ import os +import sys from pathlib import Path from typing import Optional import structlog @@ -14,12 +15,33 @@ from loggers import get_logger logger = get_logger(__name__) +def _is_wsl() -> bool: + """Detect if we are running inside WSL (Windows Subsystem for Linux).""" + if sys.platform == "win32": + return False + try: + with open("/proc/version", "r") as f: + return "microsoft" in f.read().lower() + except Exception: + return False + + +_IS_WSL: bool = _is_wsl() + + def normalize_path(path: str) -> str: """ - Convert Windows paths to WSL format if needed. + Normalize filesystem paths for cross-platform use. - Examples: + On WSL, converts Windows drive-letter paths to ``/mnt//...``. + On native Windows, keeps the drive letter and normalizes separators. + On Linux/macOS (non-WSL), paths are returned with forward slashes. + + Examples (WSL): C:\\Users\\... -> /mnt/c/Users/... + Examples (native Windows): + C:\\Users\\... -> C:/Users/... + Examples (Linux/macOS): /home/user/... -> /home/user/... (unchanged) """ if not path: @@ -27,9 +49,13 @@ def normalize_path(path: str) -> str: # Handle Windows drive letters (C:\\ or c:\\) if len(path) >= 3 and path[1] == ":" and path[2] in ("\\", "/"): - drive = path[0].lower() - rest = path[3:].replace("\\", "/") - return f"/mnt/{drive}/{rest}" + # Only map to /mnt// when running under WSL; + # on native Windows the drive letter must be preserved. + if _IS_WSL: + drive = path[0].lower() + rest = path[3:].replace("\\", "/") + return f"/mnt/{drive}/{rest}" + return path.replace("\\", "/") # Already Unix-style or relative return path.replace("\\", "/") From 362ad3606b845c51ae5bf2354ff84d14e6c3356b Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 27 Mar 2026 08:42:00 -0700 Subject: [PATCH 72/94] Update _utils.py --- unsloth/models/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unsloth/models/_utils.py b/unsloth/models/_utils.py index abdd19c615..4db06606b9 100644 --- a/unsloth/models/_utils.py +++ b/unsloth/models/_utils.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2026.3.16" +__version__ = "2026.3.17" __all__ = [ "SUPPORTS_BFLOAT16", From 5d2dca801cb18a2918a440167a480ff6bde6cdfd Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Sat, 28 Mar 2026 18:18:25 +0000 Subject: [PATCH 73/94] studio: add HF/local model selection UI for GGUF export (#4365) * feat(studio): add HF/local model selection UI for GGUF export * fix(studio):fix selector ring clipping * fix(studio): export page trust_remote_code control and label styling * fix(studio): accept hf_token in load_checkpoint orchestrator method The route was passing hf_token to load_checkpoint() but the method didn't accept it, causing a TypeError on every /api/export/load-checkpoint request. * fix(studio): clear HF model selection when input is edited Previously selectedSourceModel was only cleared when the input became empty, so editing to a different repo ID after selecting a model would silently keep the old selection. --------- Co-authored-by: Roland Tannous --- studio/backend/core/export/orchestrator.py | 2 + .../src/features/export/export-page.tsx | 771 ++++++++++++++---- 2 files changed, 615 insertions(+), 158 deletions(-) diff --git a/studio/backend/core/export/orchestrator.py b/studio/backend/core/export/orchestrator.py index a9fbe659b3..500bc9e706 100644 --- a/studio/backend/core/export/orchestrator.py +++ b/studio/backend/core/export/orchestrator.py @@ -217,6 +217,7 @@ class ExportOrchestrator: max_seq_length: int = 2048, load_in_4bit: bool = True, trust_remote_code: bool = False, + hf_token: Optional[str] = None, ) -> Tuple[bool, str]: """Load a checkpoint for export. @@ -227,6 +228,7 @@ class ExportOrchestrator: "max_seq_length": max_seq_length, "load_in_4bit": load_in_4bit, "trust_remote_code": trust_remote_code, + "hf_token": hf_token, } # Always kill existing subprocess and spawn fresh. diff --git a/studio/frontend/src/features/export/export-page.tsx b/studio/frontend/src/features/export/export-page.tsx index edf5b666a3..fd43fdb90d 100644 --- a/studio/frontend/src/features/export/export-page.tsx +++ b/studio/frontend/src/features/export/export-page.tsx @@ -3,6 +3,19 @@ import { SectionCard } from "@/components/section-card"; import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@/components/ui/input-group"; import { Select, SelectContent, @@ -11,17 +24,34 @@ import { SelectValue, } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useTrainingConfigStore } from "@/features/training"; -import { AlertCircleIcon, InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons"; +import { + listLocalModels, + type LocalModelInfo, + useTrainingConfigStore, +} from "@/features/training"; +import { + useDebouncedValue, + useHfModelSearch, + useHfTokenValidation, +} from "@/hooks"; +import { + AlertCircleIcon, + FolderSearchIcon, + InformationCircleIcon, + Key01Icon, + PackageIcon, + Search01Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { collapseAnim } from "./anim"; import type { ModelCheckpoints } from "./api/export-api"; @@ -60,6 +90,21 @@ export function ExportPage() { const [selectedModelIdx, setSelectedModelIdx] = useState(null); const [checkpoint, setCheckpoint] = useState(null); + const [sourceMode, setSourceMode] = useState<"checkpoint" | "model">( + "checkpoint", + ); + const [modelSource, setModelSource] = useState<"hf" | "local">("hf"); + const [hfExportTrustRemoteCode, setHfExportTrustRemoteCode] = + useState(true); + const [modelInput, setModelInput] = useState(""); + const [selectedSourceModel, setSelectedSourceModel] = useState( + null, + ); + const [localModelInput, setLocalModelInput] = useState(""); + const [localModels, setLocalModels] = useState([]); + const [isLoadingLocalModels, setIsLoadingLocalModels] = useState(true); + const [localModelsError, setLocalModelsError] = useState(null); + const debouncedModelQuery = useDebouncedValue(modelInput); const [exportMethod, setExportMethod] = useState(null); const [quantLevels, setQuantLevels] = useState([]); @@ -74,6 +119,9 @@ export function ExportPage() { const [exportError, setExportError] = useState(null); const [exportSuccess, setExportSuccess] = useState(false); + const hfComboboxAnchorRef = useRef(null); + const localComboboxAnchorRef = useRef(null); + const tour = useGuidedTourController({ id: "export", steps: exportTourSteps, @@ -105,6 +153,27 @@ export function ExportPage() { }; }, []); + // ---- Fetch local models for direct export ---- + useEffect(() => { + const controller = new AbortController(); + void listLocalModels(controller.signal) + .then((models) => { + if (controller.signal.aborted) return; + setLocalModels(models); + }) + .catch((error) => { + if (controller.signal.aborted) return; + setLocalModelsError( + error instanceof Error ? error.message : "Failed to load local models", + ); + }) + .finally(() => { + if (controller.signal.aborted) return; + setIsLoadingLocalModels(false); + }); + return () => controller.abort(); + }, []); + // ---- Derived state ---- const selectedModelData = useMemo( () => @@ -127,6 +196,83 @@ export function ExportPage() { const trainingMethodLabel = selectedModelData?.peft_type ? "LoRA / QLoRA" : "Full Fine-tune"; + const sourceBaseModelName = sourceMode === "model" + ? selectedSourceModel ?? "—" + : baseModelName; + + const { + results: hfResults, + isLoading: isLoadingHfModels, + error: hfSearchError, + } = useHfModelSearch(debouncedModelQuery, { + accessToken: hfToken || undefined, + excludeGguf: true, + }); + const { error: tokenValidationError, isChecking: isCheckingToken } = + useHfTokenValidation(hfToken); + + const hfResultIds = useMemo(() => { + const ids = hfResults.map((r) => r.id); + if ( + selectedSourceModel && + modelSource === "hf" && + !ids.includes(selectedSourceModel) + ) { + ids.push(selectedSourceModel); + } + return ids; + }, [hfResults, modelSource, selectedSourceModel]); + + const exportableLocalModels = useMemo( + () => + localModels.filter((m) => { + if (m.path.endsWith(".gguf")) return false; + if (m.id.toLowerCase().includes("-gguf")) return false; + return true; + }), + [localModels], + ); + + const localMetaById = useMemo(() => { + const map = new Map(); + for (const model of exportableLocalModels) map.set(model.id, model); + return map; + }, [exportableLocalModels]); + + const localResultIds = useMemo(() => { + const ids = exportableLocalModels.map((model) => model.id); + const manual = localModelInput.trim(); + if (manual && !ids.includes(manual)) { + ids.unshift(manual); + } + return ids; + }, [exportableLocalModels, localModelInput]); + + const localFilteredIds = useMemo(() => { + const q = localModelInput.trim().toLowerCase(); + if (!q) return localResultIds; + return localResultIds.filter((id) => { + const meta = localMetaById.get(id); + if (id.toLowerCase().includes(q)) return true; + if (meta?.display_name.toLowerCase().includes(q)) return true; + if (meta?.path.toLowerCase().includes(q)) return true; + return false; + }); + }, [localMetaById, localModelInput, localResultIds]); + + const exportGuideSteps = useMemo( + () => + sourceMode === "model" + ? [ + "Select a Hugging Face or local model to export from", + "GGUF is used for non-finetuned model exports", + "Pick one or more GGUF quantization levels", + "Click Export and choose your destination", + "Test your model and compare outputs in Chat", + ] + : GUIDE_STEPS, + [sourceMode], + ); // Reset checkpoint when the selected model changes useEffect(() => { @@ -144,6 +290,25 @@ export function ExportPage() { } }, [isAdapter, isQuantized, exportMethod]); + const handleSourceModeSwitch = useCallback( + (next: "checkpoint" | "model") => { + setSourceMode(next); + if (next === "model") { + setExportMethod("gguf"); + } + setSelectedSourceModel(null); + setLocalModelInput(""); + setModelInput(""); + }, + [], + ); + + useEffect(() => { + setSelectedSourceModel(null); + setLocalModelInput(""); + setModelInput(""); + }, [modelSource]); + const handleMethodChange = (method: ExportMethod) => { setExportMethod(method); if (method !== "gguf") { @@ -152,19 +317,24 @@ export function ExportPage() { }; const estimatedSize = getEstimatedSize(exportMethod, quantLevels); - const canExport = - checkpoint && + const selectedExportSource = + sourceMode === "checkpoint" ? checkpoint : selectedSourceModel; + const canExport = !!( + selectedExportSource && exportMethod && - (exportMethod !== "gguf" || quantLevels.length > 0); + (exportMethod !== "gguf" || quantLevels.length > 0) + ); // ---- Export handler ---- const handleExport = useCallback(async () => { - if (!checkpoint) return; + const source = sourceMode === "checkpoint" ? checkpoint : selectedSourceModel; + if (!source) return; - const selectedCp = checkpointsForModel.find( - (cp) => cp.display_name === checkpoint, - ); - if (!selectedCp) return; + const selectedCp = sourceMode === "checkpoint" + ? checkpointsForModel.find((cp) => cp.display_name === checkpoint) + : null; + if (sourceMode === "checkpoint" && !selectedCp) return; + const checkpointPath = selectedCp?.path; setExporting(true); setExportError(null); @@ -174,7 +344,8 @@ export function ExportPage() { // For other formats, nest under training-run/checkpoint const saveDir = exportMethod === "gguf" - ? `${baseModelName.split("/").pop() ?? selectedModelIdx ?? "model"}-finetune-gguf` + ? `${(sourceBaseModelName.split("/").pop() ?? selectedModelIdx ?? "model") + .replace(/[^a-zA-Z0-9._-]/g, "-")}-gguf` : `${selectedModelIdx ?? "model"}/${checkpoint}`; const pushToHub = destination === "hub"; const repoId = pushToHub && hfUsername && modelName @@ -183,8 +354,18 @@ export function ExportPage() { const token = pushToHub && hfToken ? hfToken : undefined; try { - // 1. Load checkpoint - await loadCheckpoint({ checkpoint_path: selectedCp.path }); + // 1. Load model source + if (sourceMode === "checkpoint") { + if (!checkpointPath) return; + await loadCheckpoint({ checkpoint_path: checkpointPath }); + } else { + await loadCheckpoint({ + checkpoint_path: source, + load_in_4bit: false, + trust_remote_code: + modelSource === "hf" ? hfExportTrustRemoteCode : true, + }); + } // 2. Run export based on method if (exportMethod === "merged") { @@ -242,16 +423,21 @@ export function ExportPage() { }, [ checkpoint, checkpointsForModel, + sourceMode, + selectedSourceModel, selectedModelIdx, selectedModelData, exportMethod, isAdapter, + sourceBaseModelName, quantLevels, destination, hfUsername, modelName, hfToken, privateRepo, + modelSource, + hfExportTrustRemoteCode, ]); // ---- Render ---- @@ -265,14 +451,14 @@ export function ExportPage() { Export Model

- Export your fine-tuned model for deployment + Export fine-tuned or base models for deployment

} title="Export Configuration" - description="Select checkpoint, method, and quantization" + description="Select source, method, and quantization" accent="emerald" featured={true} className="shadow-border ring-1 ring-border" @@ -296,11 +482,10 @@ export function ExportPage() { <> {/* Top row: Dropdowns + metadata | Guide */}
-
- {/* Training run dropdown */} -
+
+
+ + ); + })} + + +
- {/* Checkpoint dropdown */} -
- + Read more + + + + + - - - - - {checkpointsForModel.map((cp) => ( - - - {cp.display_name} - {cp.loss != null && ( - - loss: {cp.loss.toFixed(4)} + + + {checkpointsForModel.map((cp) => ( + + + {cp.display_name} + {cp.loss != null && ( + + loss: {cp.loss.toFixed(4)} + + )} - )} - - - ))} - - -
+ + ))} + + +
+ + ) : ( + +
+ + +
-
- - Training Info - -
-
- Base Model - {baseModelName} -
-
- Method - - {trainingMethodLabel} - -
-
- Checkpoints - - {checkpointsForModel.length} - -
- {isAdapter && ( -
- LoRA Rank - {loraRank} + {modelSource === "hf" ? ( + <> +
+ +
+ { + setModelInput(val); + setSelectedSourceModel(null); + }} + itemToStringValue={(id) => id} + autoHighlight={true} + > + + + + + + + {isLoadingHfModels ? ( +
+ Searching… +
+ ) : ( + No models found + )} + + {(id: string) => ( + + + {id} + + + )} + +
+
+
+ {(tokenValidationError ?? hfSearchError) && ( +

+ {tokenValidationError ?? hfSearchError} +

+ )} +
+
+ + + + + + + + Loads custom Python from the repo if the model + needs it. Turn off if you do not trust the + source. + + +
+
+ + + + + + setHfToken(e.target.value)} + /> + + {isCheckingToken && ( +

Checking token…

+ )} +
+ + ) : ( +
+ +
+ { + const next = id ?? ""; + setLocalModelInput(next); + setSelectedSourceModel(next || null); + }} + onInputValueChange={setLocalModelInput} + itemToStringValue={(id) => id} + autoHighlight={true} + > + + setSelectedSourceModel(localModelInput.trim() || null) + } + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + setSelectedSourceModel(localModelInput.trim() || null); + }} + > + + + + + + {isLoadingLocalModels ? ( +
+ Scanning... +
+ ) : localModelsError ? ( +
+ {localModelsError} +
+ ) : ( + No local models found + )} + + {(id: string) => { + const model = localMetaById.get(id); + const source = + model?.source === "hf_cache" + ? "HF cache" + : "Local dir"; + return ( + + + {model?.display_name ?? id} + + + {source} + + + ); + }} + +
+
+
+ {isLoadingLocalModels ? ( +

+ Scanning local models... +

+ ) : localModelsError ? ( +

{localModelsError}

+ ) : ( +

+ {exportableLocalModels.length > 0 + ? `${exportableLocalModels.length} local/cached models found` + : "No local models found. Enter path manually."} +

+ )}
)} + +
+

+ Direct model exports currently support GGUF only. +

+
+ + )} + + + {sourceMode === "checkpoint" && ( +
+ + Training Info + +
+
+ Base Model + {baseModelName} +
+
+ Method + + {trainingMethodLabel} + +
+
+ Checkpoints + + {checkpointsForModel.length} + +
+ {isAdapter && ( +
+ LoRA Rank + {loraRank} +
+ )} +
-
+ )}
@@ -462,7 +915,7 @@ export function ExportPage() { Quick Guide
    - {GUIDE_STEPS.map((step, i) => ( + {exportGuideSteps.map((step, i) => (
  1. {exportMethod === "gguf" && ( - + )} @@ -530,12 +985,12 @@ export function ExportPage() { Date: Sat, 28 Mar 2026 22:26:49 +0400 Subject: [PATCH 74/94] Fix blank page on Windows due to broken .js MIME type (#4674) * Fix blank page on Windows due to broken .js MIME type in registry * Update studio/backend/main.py adding defensive suggestion by gemini where we make the mimetypes specific to windows platforms Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- studio/backend/main.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/studio/backend/main.py b/studio/backend/main.py index 65f5e7fe90..67908d8617 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -23,10 +23,23 @@ if _backend_dir not in sys.path: # See: https://github.com/python/cpython/issues/102396 import _platform_compat # noqa: F401 +import mimetypes import shutil import warnings from contextlib import asynccontextmanager +# Fix broken Windows registry MIME types. Some Windows installs map .js to +# "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes +# module reads from the registry, and FastAPI/Starlette's StaticFiles uses +# mimetypes.guess_type() to set Content-Type headers. Browsers enforce strict +# MIME checking for ES module scripts (). Use \s* before > in both script and style patterns. * Address reviewer findings: SSRF, timeout crash, XML regex, dedup - SSRF: resolve hostname via getaddrinfo and reject private, loopback, link-local, multicast, and reserved addresses before fetching - Timeout: handle timeout=None (unlimited mode) in URL fetch path by defaulting to 60s instead of crashing on min(None, 60) - Download cap: read at most max_chars*4+1 bytes instead of the full response body before truncating - XML regex: match both and markup in the history/stream cleanup (inference.py) - CodeQL: use [^>]* in closing script/style tags to handle any whitespace or attributes before > - Dedup: track whether each tool call failed so retries after transient errors are allowed; only block consecutive identical calls that both succeeded - Final-answer synthesis: guard on max_tool_iterations > 0 so callers who disable tools do not get a false "used all calls" turn * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix redirect SSRF, SSE streaming regression, dedup off-by-one - SSRF redirect bypass: disable auto-redirect in urllib, manually follow up to 5 hops with host validation at each step. Prevents public URLs from redirecting to loopback/private targets. - SSE streaming: track prev_text on the raw cumulative and strip XML from the delta only, so completed tool_call tags do not cause the cumulative to shrink and drop trailing real text. - Dedup off-by-one: check the immediately previous call (window=1) instead of requiring 2 matching history entries, so the second identical successful call is blocked rather than the third. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix redirect HTTPError handling and tighten error prefixes - Redirect fix: urllib raises HTTPError (not a normal response) when the redirect handler returns None. Catch HTTPError for 3xx codes and extract the Location header from the exception object. - Error prefixes: remove overly broad "No " prefix that matched "No results found." (a valid empty-search outcome, not an error). Replace with specific prefixes like "Blocked:", "No query provided", "Failed to resolve". This ensures empty search results are correctly classified as non-errors for duplicate-call tracking. * Fix SSE cross-chunk XML leaks, cleanup review findings - SSE streaming: sanitize the full cumulative text before diffing against the previous sanitized snapshot, so XML tags that span chunk boundaries are stripped correctly. The previous delta-based approach leaked split tags. - DRAINING fallback: use _strip_tool_markup() helper instead of a manual regex that only handled but not . - Move hashlib import, _TOOL_XML_RE compile, and datetime import to module level per style guide. - Remove unused _hit_tool_cap variable. * Fix DNS rebinding, charset detection, HTTPError handling, dedup double-record - DNS rebinding: resolve hostname once via getaddrinfo, pin the returned IP, rewrite the URL to connect to the pinned IP with a Host header. Each redirect hop re-resolves and re-validates. Closes the TOCTOU window between validation and connection. - Charset: use resp.headers.get_content_charset() instead of hardcoding utf-8, so pages with other encodings decode correctly. - HTTPError: return descriptive "HTTP {code} {reason}" instead of re-raising into a generic "Search failed" message. - Dedup: remove redundant _record_tool_call in the duplicate branch; the single call at the end of the loop handles all cases. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- studio/backend/core/inference/llama_cpp.py | 103 +++++++-- studio/backend/core/inference/tools.py | 204 +++++++++++++++++- studio/backend/models/inference.py | 2 +- studio/backend/routes/inference.py | 81 ++++++- .../tests/tool_calling_benchmark_results.md | 62 ++++++ .../chat/stores/chat-runtime-store.ts | 2 +- 6 files changed, 428 insertions(+), 26 deletions(-) create mode 100644 studio/backend/tests/tool_calling_benchmark_results.md diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index f5361a6e8c..c1f87ff936 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -10,7 +10,9 @@ through its OpenAI-compatible /v1/chat/completions endpoint. import atexit import contextlib +import hashlib import json +import re import struct import structlog from loggers import get_logger @@ -2120,7 +2122,7 @@ class LlamaCppBackend: stop: Optional[list[str]] = None, cancel_event: Optional[threading.Event] = None, enable_thinking: Optional[bool] = None, - max_tool_iterations: int = 10, + max_tool_iterations: int = 25, auto_heal_tool_calls: bool = True, tool_call_timeout: int = 300, session_id: Optional[str] = None, @@ -2172,6 +2174,29 @@ class LlamaCppBackend: ) _MAX_BUFFER_CHARS = 32 + # ── Duplicate tool-call detection ──────────────────────── + # Track recent (tool_name, arguments) hashes to detect loops + # where the model repeats the exact same call. Retries after + # a transient failure are allowed (only block when the previous + # identical call succeeded). + _tool_call_history: list[tuple[str, bool]] = [] # (key, failed) + + def _tool_call_key(name: str, args: dict) -> str: + raw = json.dumps({"t": name, "a": args}, sort_keys = True) + return hashlib.md5(raw.encode()).hexdigest() + + def _is_duplicate_call(name: str, args: dict) -> bool: + """Block if the immediately previous call was identical and succeeded.""" + if not _tool_call_history: + return False + key = _tool_call_key(name, args) + last_key, last_failed = _tool_call_history[-1] + return last_key == key and not last_failed + + def _record_tool_call(name: str, args: dict, failed: bool) -> None: + key = _tool_call_key(name, args) + _tool_call_history.append((key, failed)) + for iteration in range(max_tool_iterations): if cancel_event is not None and cancel_event.is_set(): return @@ -2568,6 +2593,11 @@ class LlamaCppBackend: # Merge accumulated metrics from prior tool # iterations so they are not silently dropped. yield {"type": "status", "text": ""} + if content_accum: + # Strip leaked tool-call XML before yielding + content_accum = _strip_tool_markup( + content_accum, final = True + ) if content_accum: yield {"type": "content", "text": content_accum} _fu = _iter_usage or {} @@ -2661,16 +2691,27 @@ class LlamaCppBackend: "arguments": arguments, } - _effective_timeout = ( - None if tool_call_timeout >= 9999 else tool_call_timeout - ) - result = execute_tool( - tool_name, - arguments, - cancel_event = cancel_event, - timeout = _effective_timeout, - session_id = session_id, - ) + # ── Duplicate call detection ────────────── + if _is_duplicate_call(tool_name, arguments): + result = ( + "You already made this exact call. " + "Do not repeat the same tool call. " + "Try a different approach: fetch a URL " + "from previous results, use Python to " + "process data you already have, or " + "provide your final answer now." + ) + else: + _effective_timeout = ( + None if tool_call_timeout >= 9999 else tool_call_timeout + ) + result = execute_tool( + tool_name, + arguments, + cancel_event = cancel_event, + timeout = _effective_timeout, + session_id = session_id, + ) yield { "type": "tool_end", @@ -2679,10 +2720,32 @@ class LlamaCppBackend: "result": result, } + # Nudge model to try a different approach on errors + _error_prefixes = ( + "Error", + "Search failed", + "Execution error", + "Blocked:", + "Exit code", + "Failed to fetch", + "Failed to resolve", + "No query provided", + ) + _is_error = isinstance(result, str) and result.lstrip().startswith( + _error_prefixes + ) + _record_tool_call(tool_name, arguments, failed = _is_error) + _result_content = result + if _is_error: + _result_content = ( + result + "\n\nThe tool call encountered an issue. " + "Please try a different approach or rephrase your request." + ) + tool_msg = { "role": "tool", "name": tool_name, - "content": result, + "content": _result_content, } tool_call_id = tc.get("id") if tool_call_id: @@ -2699,6 +2762,22 @@ class LlamaCppBackend: return raise + # ── Tool iteration cap reached -- synthesize final answer ── + # The model used all iterations without producing a final text + # response. Inject a nudge so the final streaming pass produces + # a useful answer instead of continuing to request tools. + if max_tool_iterations > 0: + conversation.append( + { + "role": "user", + "content": ( + "You have used all available tool calls. Based on " + "everything you have found so far, provide your final " + "answer now. Do not call any more tools." + ), + } + ) + # Clear status yield {"type": "status", "text": ""} diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 55bfa095f9..65302fe2f3 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -57,16 +57,23 @@ WEB_SEARCH_TOOL = { "type": "function", "function": { "name": "web_search", - "description": "Search the web for current information, recent events, or facts you are uncertain about.", + "description": ( + "Search the web and fetch page content. Returns snippets for all results. " + "Use the url parameter to fetch full page text from a specific URL." + ), "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "The search query", - } + }, + "url": { + "type": "string", + "description": "A URL to fetch full page content from (instead of searching). Use this to read a page found in search results.", + }, }, - "required": ["query"], + "required": [], }, }, } @@ -131,7 +138,11 @@ def execute_tool( ) effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout if name == "web_search": - return _web_search(arguments.get("query", ""), timeout = effective_timeout) + return _web_search( + arguments.get("query", ""), + url = arguments.get("url"), + timeout = effective_timeout, + ) if name == "python": return _python_exec( arguments.get("code", ""), cancel_event, effective_timeout, session_id @@ -143,9 +154,180 @@ def execute_tool( return f"Unknown tool: {name}" -def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT) -> str: - """Search the web using DuckDuckGo and return formatted results.""" - if not query.strip(): +_MAX_PAGE_CHARS = 16000 # limit fetched page text +_MAX_FETCH_BYTES = _MAX_PAGE_CHARS * 4 + 1 # cap raw download size + + +def _validate_and_resolve_host(hostname: str, port: int) -> tuple[bool, str, str]: + """Resolve *hostname*, reject non-public IPs, return a pinned IP string. + + Returns ``(ok, reason_or_empty, resolved_ip)``. The caller should + connect to *resolved_ip* (with a ``Host`` header) to prevent DNS + rebinding between validation and the actual fetch. + """ + import ipaddress + import socket + + try: + infos = socket.getaddrinfo(hostname, port, type = socket.SOCK_STREAM) + except OSError as e: + return False, f"Failed to resolve host: {e}", "" + + if not infos: + return False, f"Failed to resolve host: no addresses for {hostname!r}", "" + + for *_, sockaddr in infos: + ip = ipaddress.ip_address(sockaddr[0]) + if ( + ip.is_private + or ip.is_loopback + or ip.is_link_local + or ip.is_multicast + or ip.is_reserved + or ip.is_unspecified + ): + return False, f"Blocked: refusing to fetch non-public address {ip}.", "" + + # Return the first resolved address for pinning + first_ip = infos[0][4][0] + return True, "", first_ip + + +def _fetch_page_text( + url: str, max_chars: int = _MAX_PAGE_CHARS, timeout: int = 30 +) -> str: + """Fetch a URL and return plain text content (HTML tags stripped). + + Blocks private/loopback/link-local targets (SSRF protection) and caps + the download size to avoid unbounded memory usage. + """ + import re as _re + from urllib.parse import urlparse + + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return f"Blocked: only http/https URLs are allowed (got {parsed.scheme!r})." + if not parsed.hostname: + return "Blocked: URL is missing a hostname." + + port = parsed.port or (443 if parsed.scheme == "https" else 80) + ok, reason, pinned_ip = _validate_and_resolve_host(parsed.hostname, port) + if not ok: + return reason + + try: + import urllib.request + from urllib.error import HTTPError as _HTTPError + from urllib.parse import urljoin, urlunparse + + # Disable auto-redirect so we can validate each hop for SSRF. + # urllib raises HTTPError for 3xx when the handler returns None, + # so we catch that and extract the Location header manually. + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + opener = urllib.request.build_opener(_NoRedirect) + max_bytes = max_chars * 4 + 1 + current_url = url + current_host = parsed.hostname + + for _hop in range(5): + # Pin to the validated IP to prevent DNS rebinding. + # Rewrite the URL to use the IP and set the Host header. + cp = urlparse(current_url) + ip_netloc = f"{pinned_ip}:{cp.port}" if cp.port else pinned_ip + pinned_url = urlunparse(cp._replace(netloc = ip_netloc)) + + req = urllib.request.Request( + pinned_url, + headers = { + "User-Agent": "UnslothStudio/1.0", + "Host": current_host, + }, + ) + try: + resp = opener.open(req, timeout = timeout) + except _HTTPError as e: + if e.code not in (301, 302, 303, 307, 308): + return ( + f"Failed to fetch URL: HTTP {e.code} {getattr(e, 'reason', '')}" + ) + location = e.headers.get("Location") + if not location: + return "Failed to fetch URL: redirect missing Location header." + current_url = urljoin(current_url, location) + rp = urlparse(current_url) + if rp.scheme not in ("http", "https") or not rp.hostname: + return "Blocked: redirect target is not a valid http/https URL." + rp_port = rp.port or (443 if rp.scheme == "https" else 80) + ok2, reason2, pinned_ip = _validate_and_resolve_host( + rp.hostname, + rp_port, + ) + if not ok2: + return reason2 + current_host = rp.hostname + continue + # Success -- read capped body + raw_bytes = resp.read(max_bytes) + break + else: + return "Failed to fetch URL: too many redirects." + + charset = resp.headers.get_content_charset() or "utf-8" + raw_html = raw_bytes.decode(charset, errors = "replace") + except _HTTPError as e: + return f"Failed to fetch URL: HTTP {e.code} {getattr(e, 'reason', '')}" + except Exception as e: + return f"Failed to fetch URL: {e}" + + # Convert HTML to text -- prefer html2text for clean markdown output + try: + import html2text as _h2t + + converter = _h2t.HTML2Text() + converter.ignore_links = False + converter.ignore_images = True + converter.body_width = 0 # no wrapping + text = converter.handle(raw_html).strip() + except ImportError: + # Fallback: regex-based stripping + text = _re.sub( + r"]*>.*?]*>", + "", + raw_html, + flags = _re.DOTALL | _re.IGNORECASE, + ) + text = _re.sub( + r"]*>.*?]*>", "", text, flags = _re.DOTALL | _re.IGNORECASE + ) + text = _re.sub(r"<[^>]+>", " ", text) + text = _re.sub(r"\s+", " ", text).strip() + + if not text: + return "(page returned no readable text)" + if len(text) > max_chars: + text = text[:max_chars] + f"\n\n... (truncated, {len(text)} chars total)" + return text + + +def _web_search( + query: str, + max_results: int = 5, + timeout: int = _EXEC_TIMEOUT, + url: str | None = None, +) -> str: + """Search the web using DuckDuckGo and return formatted results. + + If ``url`` is provided, fetches that page directly instead of searching. + """ + # Direct URL fetch mode + if url and url.strip(): + fetch_timeout = 60 if timeout is None else min(timeout, 60) + return _fetch_page_text(url.strip(), timeout = fetch_timeout) + + if not query or not query.strip(): return "No query provided." try: from ddgs import DDGS @@ -160,7 +342,13 @@ def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT) f"URL: {r.get('href', '')}\n" f"Snippet: {r.get('body', '')}" ) - return "\n\n---\n\n".join(parts) + text = "\n\n---\n\n".join(parts) + text += ( + "\n\n---\n\nIMPORTANT: These are only short snippets. " + "To get the full page content, call web_search with " + 'the url parameter (e.g. {"url": ""}).' + ) + return text except Exception as e: return f"Search failed: {e}" diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py index aabfba9b3a..77f70b9bd6 100644 --- a/studio/backend/models/inference.py +++ b/studio/backend/models/inference.py @@ -344,7 +344,7 @@ class ChatCompletionRequest(BaseModel): description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.", ) max_tool_calls_per_message: Optional[int] = Field( - 10, + 25, ge = 0, description = "[x-unsloth] Maximum number of tool call iterations per message (0 = disabled, 9999 = unlimited).", ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 1a94256059..9bce371775 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -86,8 +86,15 @@ import io import wave import base64 import numpy as np +from datetime import date as _date router = APIRouter() + +# Regex for stripping leaked tool-call XML from assistant messages/stream +_TOOL_XML_RE = _re.compile( + r".*?|.*?", + _re.DOTALL, +) logger = get_logger(__name__) @@ -1078,6 +1085,68 @@ async def openai_chat_completions( else: tools_to_use = ALL_TOOLS + # ── Tool-use system prompt nudge ────────────────────── + _tool_names = {t["function"]["name"] for t in tools_to_use} + _has_web = "web_search" in _tool_names + _has_code = "python" in _tool_names or "terminal" in _tool_names + + _date_line = f"The current date is {_date.today().isoformat()}." + + _web_tips = ( + "When you search and find a relevant URL in the results, " + "fetch its full content by calling web_search with the url parameter. " + "Do not repeat the same search query. If a search returns " + "no useful results, try rephrasing or fetching a result URL directly." + ) + _code_tips = ( + "Use code execution for math, calculations, data processing, " + "or to parse and analyze information from tool results." + ) + + if _has_web and _has_code: + _nudge = ( + _date_line + " " + "You have access to tools. When appropriate, prefer using " + "tools rather than answering from memory. " + + _web_tips + + " " + + _code_tips + ) + elif _has_code: + _nudge = ( + _date_line + " " + "You have access to tools. When appropriate, prefer using " + "code execution rather than answering from memory. " + _code_tips + ) + elif _has_web: + _nudge = ( + _date_line + " " + "You have access to tools. When appropriate, prefer using " + "web search for up-to-date or uncertain factual " + "information rather than answering from memory. " + _web_tips + ) + else: + _nudge = "" + + if _nudge: + # Append nudge to system prompt (preserve user's prompt) + if system_prompt: + system_prompt = system_prompt.rstrip() + "\n\n" + _nudge + else: + system_prompt = _nudge + # Rebuild gguf_messages with updated system prompt + gguf_messages = [] + if system_prompt: + gguf_messages.append({"role": "system", "content": system_prompt}) + gguf_messages.extend(chat_messages) + + # ── Strip stale tool-call XML from conversation history ─ + for _msg in gguf_messages: + if _msg.get("role") == "assistant" and isinstance( + _msg.get("content"), str + ): + _msg["content"] = _TOOL_XML_RE.sub("", _msg["content"]).strip() + def gguf_generate_with_tools(): return llama_backend.generate_chat_completion_with_tools( messages = gguf_messages, @@ -1096,7 +1165,7 @@ async def openai_chat_completions( else True, max_tool_iterations = payload.max_tool_calls_per_message if payload.max_tool_calls_per_message is not None - else 10, + else 25, tool_call_timeout = payload.tool_call_timeout if payload.tool_call_timeout is not None else 300, @@ -1158,9 +1227,13 @@ async def openai_chat_completions( continue # "content" type -- cumulative text - cumulative = event.get("text", "") - new_text = cumulative[len(prev_text) :] - prev_text = cumulative + # Sanitize the full cumulative then diff against + # the last sanitized snapshot so cross-chunk XML + # tags are handled correctly. + raw_cumulative = event.get("text", "") + clean_cumulative = _TOOL_XML_RE.sub("", raw_cumulative) + new_text = clean_cumulative[len(prev_text) :] + prev_text = clean_cumulative if not new_text: continue chunk = ChatCompletionChunk( diff --git a/studio/backend/tests/tool_calling_benchmark_results.md b/studio/backend/tests/tool_calling_benchmark_results.md new file mode 100644 index 0000000000..c2b0687895 --- /dev/null +++ b/studio/backend/tests/tool_calling_benchmark_results.md @@ -0,0 +1,62 @@ +# GGUF Tool Calling Benchmark Results + +Prompt: "List and categorize all the songs that charted #3 on the Billboard Hot 100 in 2015." +10 runs per configuration, web search + code execution + thinking enabled. +GPU: NVIDIA B200, CUDA_VISIBLE_DEVICES=2. + +Ground truth: 4 songs peaked at #3 in 2015 -- "Love Me like You Do" (Ellie Goulding), "Earned It" (The Weeknd), "Watch Me" (Silento), "Drag Me Down" (One Direction). + +## Cartesian Grid: Model x Quant x KV Cache + +| Model | Quant | KV Cache | OK/10 | Avg Time | Avg Tools | XML Leaks | URL Fetch | Peak3 Avg | All 4/4 | Best Songs | +|-------|-------|----------|-------|----------|-----------|-----------|-----------|-----------|---------|------------| +| 4B | UD-Q4_K_XL | f16 | 10/10 | 9.8s | 3.5 | 0/10 | 4/10 | 0.8/4 | 2/10 | 9 | +| 4B | UD-Q4_K_XL | bf16 | 10/10 | 10.6s | 4.5 | 0/10 | 4/10 | 0.4/4 | 1/10 | 5 | +| 4B | Q8_0 | f16 | 10/10 | 4.9s | 2.4 | 0/10 | 8/10 | 0.4/4 | 1/10 | 5 | +| 4B | Q8_0 | bf16 | 10/10 | 8.0s | 3.0 | 0/10 | 5/10 | 0.0/4 | 0/10 | 0 | +| 9B | UD-Q4_K_XL | f16 | 10/10 | 6.7s | 2.0 | 0/10 | 5/10 | 0.0/4 | 0/10 | 3 | +| 9B | UD-Q4_K_XL | bf16 | 9/10 | 49.5s | 2.4 | 0/10 | 5/10 | 0.0/4 | 0/10 | 1 | +| 9B | Q8_0 | f16 | 10/10 | 7.4s | 2.5 | 0/10 | 5/10 | 0.0/4 | 0/10 | 2 | +| 9B | Q8_0 | bf16 | 10/10 | 10.4s | 2.7 | 0/10 | 6/10 | 1.0/4 | 2/10 | 15 | +| **27B** | **UD-Q4_K_XL** | **bf16** | **9/10** | **131.1s** | **13.8** | **0/10** | **7/10** | **2.7/4** | **6/10** | **27** | +| 27B | UD-Q4_K_XL | f16 | 7/10 | 201.6s | 14.1 | 0/10 | 8/10 | 2.0/4 | 5/10 | 26 | +| 27B | Q8_0 | f16 | 4/10 | 312.5s | 16.0 | 1/10 | 10/10 | 2.4/4 | 6/10 | 28 | +| 27B | Q8_0 | bf16 | 5/10 | 258.4s | 16.5 | 2/10 | 10/10 | 0.9/4 | 1/10 | 27 | +| 35B-A3B | UD-Q4_K_XL | f16 | 3/10 | 353.6s | 14.7 | 1/10 | 6/10 | 1.2/4 | 3/10 | 27 | +| 35B-A3B | UD-Q4_K_XL | bf16 | 3/10 | 356.2s | 17.2 | 1/10 | 8/10 | 1.6/4 | 4/10 | 27 | +| 35B-A3B | Q8_0 | f16 | 2/10 | 372.1s | 17.6 | 1/10 | 7/10 | 1.2/4 | 3/10 | 26 | +| 35B-A3B | Q8_0 | bf16 | 6/10 | 267.7s | 17.5 | 1/10 | 8/10 | 2.4/4 | 6/10 | 27 | + +**Column definitions:** +- **Peak3 Avg**: Average number of correct peak-#3 songs found per run (out of 4) +- **All 4/4**: Runs where all 4 correct songs were identified +- **Best Songs**: Maximum number of Billboard 2015 songs mentioned in any single run (out of 31 tracked) +- **URL Fetch**: Runs where the model used web_search with `url` parameter to fetch full page content + +## Key Findings + +1. **27B UD-Q4_K_XL + bf16 KV is the sweet spot.** 6/10 runs found all 4 correct songs, 0 XML leaks, 131s average. Best balance of accuracy, speed, and reliability. + +2. **Larger models use tools more effectively.** 27B and 35B-A3B models used 13-17 tool calls per query (vs 2-4 for 4B/9B), performing multiple searches and URL fetches to find the answer. + +3. **27B Q8_0 had the highest raw accuracy (6/10 all-4/4) but lower reliability** -- only 4/10 OK runs due to timeouts on long agentic chains. The UD-Q4_K_XL quant is more practical. + +4. **4B models were fastest (5-10s) but least accurate.** They occasionally found all 4 songs (2/10 best case) when they happened to fetch the right Wikipedia page. + +5. **9B was surprisingly weaker than 4B on this task.** It used fewer tool calls and rarely extracted song data from fetched pages. The 9B model may need higher temperature or different prompting for this specific task type. + +6. **35B-A3B had reliability issues.** Most runs timed out or errored due to slow per-token generation with many tool iterations. When it completed (2-6/10 OK), accuracy was comparable to 27B. + +7. **bf16 KV cache had mixed effects.** For 27B it improved both speed (131s vs 202s) and accuracy (6/10 vs 5/10 all-4/4). For smaller models it had no consistent benefit. + +8. **XML leaks are nearly eliminated.** 0/10 for all 4B and 9B configs, and only 1-2/10 for the largest models (which generate much more text in complex agentic loops). + +## Before vs After (4B UD-Q4_K_XL, f16 KV) + +| Metric | Before Changes | After Changes | +|--------|---------------|---------------| +| XML leaks | 10/10 | 0/10 | +| URL fetches | 0/10 | 4/10 | +| Peak3 accuracy | 0.0/4 | 0.8/4 | +| Runs with all 4 songs | 0/10 | 2/10 | +| Avg time | 12.3s | 9.8s | diff --git a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts index ca1044b3dc..8cea234f21 100644 --- a/studio/frontend/src/features/chat/stores/chat-runtime-store.ts +++ b/studio/frontend/src/features/chat/stores/chat-runtime-store.ts @@ -224,7 +224,7 @@ export const useChatRuntimeStore = create((set) => ({ toolStatus: null, generatingStatus: null, autoHealToolCalls: loadBool(AUTO_HEAL_TOOL_CALLS_KEY, true), - maxToolCallsPerMessage: loadInt(MAX_TOOL_CALLS_KEY, 10), + maxToolCallsPerMessage: loadInt(MAX_TOOL_CALLS_KEY, 25), toolCallTimeout: loadInt(TOOL_CALL_TIMEOUT_KEY, 5), kvCacheDtype: null, loadedKvCacheDtype: null,